refactor(scope): bind the parent link once and gate re-linking behind the binding

setScopeParent could re-link any key from anywhere, leaving the
blank-session-only recompose rule entirely to caller discipline. The
relation now binds once — a second bind throws — and re-linking exists
only on the ScopeParentBinding returned to the original binder, the
private-capability shape the package conventions prescribe for a
single-caller operation. The preset roster keeps each composed agent's
binding in a WeakMap keyed by the agent, making it the sole authority
that can move an agent between standing compositions; the blank-session
contract itself stays with the gateway, which alone can see what a
session logged.
This commit is contained in:
Yichen Jiang
2026-08-10 11:17:04 +08:00
parent 51ca900d4e
commit 1eb9acaba9
9 changed files with 83 additions and 39 deletions

View File

@@ -38,25 +38,49 @@ const carrierKeys = new WeakMap<object, ScopeKey | undefined>()
*/
const scopeParents = new WeakMap<ScopeKey, ScopeKey>()
/**
* Record `parent` as `key`'s enclosing scope.
*
* Ordinarily set once when the child scope is minted ({@link createScope}'s
* `parent` option). Re-linking an existing key to a different parent is the
* blank-session recompose operation: valid only while nothing produced under
* the old parent is retained, which is the caller's contract to uphold — this
* relation cannot see what a session logged. A link that would close a cycle
* is rejected, because every chain consumer walks parents to the root.
* @param key - the child scope key.
* @param parent - its enclosing scope key.
*/
export function setScopeParent(key: ScopeKey, parent: ScopeKey): void {
/** The privileged handle to move one scope key's parent link. */
export interface ScopeParentBinding {
/**
* Re-link the bound key to a different parent, with the same cycle check as
* the bind. Valid only while nothing produced under the old parent is
* retained — the blank-session recompose contract, which the holder upholds
* because this relation cannot see what a session logged.
* @param parent - the new enclosing scope key.
*/
rebind(parent: ScopeKey): void
}
/** Cycle-checked write shared by the bind and every rebind. */
function linkScopeParent(key: ScopeKey, parent: ScopeKey): void {
for (let cursor: ScopeKey | undefined = parent; cursor !== undefined; cursor = scopeParents.get(cursor)) {
if (cursor === key) throw new Error('dsh-scope: scope parent link would form a cycle')
}
scopeParents.set(key, parent)
}
/**
* Bind `parent` as `key`'s enclosing scope, once.
*
* A key that already has a parent throws: there is no open re-link path, so a
* scope's ancestry cannot be moved by anyone but the original binder, who
* alone receives the {@link ScopeParentBinding}. A link that would close a
* cycle is rejected, because every chain consumer walks parents to the root.
* @param key - the child scope key.
* @param parent - its enclosing scope key.
* @returns the binding that alone may re-link this key.
*/
export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding {
if (scopeParents.has(key)) {
throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind')
}
linkScopeParent(key, parent)
return {
rebind(next: ScopeKey): void {
linkScopeParent(key, next)
},
}
}
/**
* Read one key's enclosing scope.
* @param key - the scope key to inspect.
@@ -98,7 +122,7 @@ function scope(): void {}
/** Options accepted by {@link createScope}. */
export interface CreateScopeOptions {
/** Enclosing scope recorded via {@link setScopeParent} before the scope is usable. */
/** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */
parent?: ScopeKey
}
@@ -111,7 +135,7 @@ export interface CreateScopeOptions {
* @returns the scoped context and exact/shared disposal boundaries.
*/
export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope {
if (options?.parent !== undefined) setScopeParent(key, options.parent)
if (options?.parent !== undefined) bindScopeParent(key, options.parent)
const fiber = ctx.plugin(scope)
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
let disposing: Promise<void> | undefined
@@ -134,7 +158,7 @@ export function scopeOf(ctx: Context): ScopeKey | undefined {
/**
* Build an opaque receiver that preserves the base filter, admits untagged
* listeners globally, and admits tagged listeners for a matching key or any
* of its ancestors ({@link setScopeParent}): a listener owned by an enclosing
* of its ancestors ({@link bindScopeParent}): a listener owned by an enclosing
* scope receives every descendant scope's events, which is what lets one
* standing composition observe each of the agents composed under it. A tag
* BELOW the dispatch key stays excluded — events flow up the chain, never