14 KiB
RFC: Scoped-layers store — one aggregate layer per scope behind a scheduling helper
Status: proposed
English | 中文
Problem
Agent scoping (the agent-scope RFC, runtime design) made "a registry with a global layer plus per-agent layers" a recurring shape, and every occurrence is hand-written. Seven registration sites exist today — tools.register/tools.restrict/tools.guard in dsh-tools and section/tools/variable/protect in dsh-system-prompt — each pairing a global container with its own Map<ScopeKey, container> and repeating the same 10-15-line effect choreography: read the calling context's tag, get-or-create the layer, validate, mutate, yield a rollback that deletes the entry, reclaims the emptied layer, and emits the change event, then emit and return the exact cordis effect disposer.
Beyond the duplication, the risk concentrates in the choreography details:
- The rollback must be collected before the change emit (so a throwing listener unwinds the insertion instead of leaking it)
- The returned disposer must be cordis's own function (a wrapper silently breaks nested ordered teardown)
- Emptied scoped layers must be reclaimed (a disposed agent must not leave residue keyed by its dead
ScopeKey)
Every new consumer has to rewrite all of that correctly, and the copies have already diverged stylistically — two private layerFor helpers in dsh-tools, four inline IIFEs in dsh-system-prompt.
Finally, one agent's contribution to one service is scattered across several maps that know nothing of each other — there is no object that means "what this scope contributes here" — and the consumer count keeps growing: guards and prompt protections landed recently, and per-agent fs/* policy, llm/* overrides, and per-agent compaction policy are all queued on the same pattern.
Proposal
dsh-scope gains a store module (a new store.ts under its src/, peer-dependent on cordis only, key-agnostic) built around one division of labor: business logic lives in a layer class; the helper only schedules layers. One helper instance per service; the value in its map is the aggregate of everything one scope contributes to that service.
ScopedLayers<L>— a concrete scheduler, never subclassed. It owns the global layer plus oneMap<ScopeKey, L>, builds layers on demand asnew layerClass(scope, this), reclaims a layer whenisEmpty(), and funnels every write througheffect(ctx, action, options?). The singlectxparameter decides both the visible layer (scopeOf(ctx)) and the owning fiber (ctx.effect), so "visible to X, disposed with Y" stays unrepresentable — the same shape argument the agent-scope RFC used against explicit scope parameters. Actions may produce one undo, an iterable of undos, a promise, or an async iterable — the four shapes of cordisEffect— and undos may be async. The helper seals collected undos (run in LIFO), empty-layer reclamation, and the change notification into one disposer, and hands cordis that disposer before the notification runs: a throwing change listener therefore makes cordis execute the already-collected rollback and rethrow, exactly like the hand-written yield-before-emit today. Reads areglobal/peekplus three selector primitives lifting the table views across the two layers —merge(named entries, scoped shadows global, global position preserved, optional admit predicate),values(concatenation including anonymous entries, deliberately no shadowing),keys(the pre-restriction name universe) — and array-returningforEach/filter/mapover all layers.createLayer({ name: table<V>(kind) })— a class factory in thedefineToolDSL tradition. The generated base class builds every declared table in its constructor, threads the scope down, receives the sibling back-reference (protected readonly layers: ScopedLayers<this>, injected by the helper at construction; polymorphicthisnarrows it in subclasses), and aggregatesisEmpty()over the declared tables.layer.<table>is a fully typed mapped property, so a misspelled table name is a compile error; the table namesscope,isEmpty, andlayersare reserved and throw. Business subclasses add domain methods in the class body — single-layer queries, registration validations, and cross-layer reads throughthis.layers(writes must still go througheffect); a fully custom layer may instead implement the one-methodScopeLayerinterface (isEmpty()).Entries<V>— the canned table: named entries (insert, same-layer duplicates throw one standardized message pair pointing atagent.ctx) and anonymous entries (append, process-unique symbol keys, O(1) undo removal) share one insertion-ordered map; read views (keys/entries/values) return array snapshots.
dsh-tools migrates its three tables into one ToolLayer (domain methods addRestriction — empty-filter/read-once/reserved-name/known-names validation with the reserved list passed in as data, since it reads service state — plus admits and guardReason), and dsh-system-prompt its four into one PromptLayer (addProtection with the global-conflict self-check via the back-reference, plus the shadowedSections predicate). Every facade becomes a single effect call carrying per-call label, silent (guards emit no change event), or scopedOnly (boolean, or a string carrying the domain error message) options. assemble stays in the facade for three hard reasons: it has no legal receiver (the subject scope's layer may not exist, and reads never create layers), shadowing forces merge-before-evaluate (per-layer rendering would evaluate shadowed providers, an observable change), and the assemble waterfall, toolOrder, and protection restore need service-level resources a layer must not hold.
Migration is behavior-preserving with two declared exceptions: the three duplicate-name messages unify into one template (tests asserting the old wording update in the same change), and validations move relative to the effect boundary (restrict/protect checks move inside the action, the variable name regex moves to the facade), so the error order for multiply-invalid inputs can change while every single-fault path is unchanged. Two knowingly unobservable differences: an aggregate layer is reclaimed only when all its tables are empty, and read views are snapshots rather than live containers (visible only to a callback that registers during its own iteration).
API sketch
interface ScopeLayer {
isEmpty(): boolean
}
type LayerClass<L extends ScopeLayer> = new (scope: ScopeKey | undefined, layers: ScopedLayers<L>) => L
declare function table<V>(kind: string): TableSpec<V>
declare function createLayer<S extends Record<string, TableSpec<unknown>>>(
spec: S,
): LayerClass<ScopeLayer & { readonly [K in keyof S]: Entries<EntryTypeOf<S[K]>> }>
type Undo = () => unknown
type LayerAction<L> = (layer: L) =>
| Undo
| Iterable<Undo, void, void>
| Promise<Undo>
| AsyncIterable<Undo, void, void>
class ScopedLayers<L extends ScopeLayer> {
constructor(layerClass: LayerClass<L>, options: { label: string; onChange?: () => void })
readonly global: L
peek(scope: ScopeKey | undefined): L | undefined
merge<T>(scope: ScopeKey | undefined, pick: (layer: L) => Entries<T>, admitGlobal?: (name: string) => boolean): Map<string, T>
values<T>(scope: ScopeKey | undefined, pick: (layer: L) => Entries<T>): T[]
keys<T>(scope: ScopeKey | undefined, pick: (layer: L) => Entries<T>): string[]
effect(ctx: Context, action: LayerAction<L>, options?: { label?: string; silent?: boolean; scopedOnly?: boolean | string }): () => Promise<void> | void
forEach(fn: (layer: L, scope: ScopeKey | undefined) => void): void
filter(fn: (layer: L, scope: ScopeKey | undefined) => boolean): L[]
map<T>(fn: (layer: L, scope: ScopeKey | undefined) => T): T[]
}
class Entries<V> {
constructor(kind: string, scope: ScopeKey | undefined)
insert(name: string, value: V): () => void
append(value: V): () => void
get(name: string): V | undefined
has(name: string): boolean
keys(): string[]
entries(): ReadonlyArray<readonly [string, V]>
values(): readonly V[]
isEmpty(): boolean
}
What a migrated consumer looks like — the heaviest current site shrinks from 30+ lines of choreography to a declaration and one-line facades:
class ToolLayer extends createLayer({
tools: table<ToolDefinition>('tool'),
restrictions: table<ToolRestriction>('tool restriction'),
guards: table<ToolGuardRegistration>('tool guard'),
}) {
addRestriction(filter: ToolRestriction, reserved: readonly string[]): () => void { /* validate, snapshot, append */ }
admits(name: string): boolean { /* intersection over this.restrictions.values() */ }
guardReason(view: Readonly<ToolExecution>): string | undefined { /* first monotonic denial */ }
}
class ToolRegistry extends Service {
private readonly layers = new ScopedLayers(ToolLayer, {
label: 'tools',
onChange: () => this.ctx.emit('tools/change'),
})
register(definition: ToolDefinition): () => Promise<void> | void {
return this.layers.effect(this.ctx,
layer => layer.tools.insert(definition.name, definition),
{ label: 'tools.register()' })
}
visible(scope?: ScopeKey): ToolDefinition[] {
return Array.from(this.layers.merge(scope, layer => layer.tools, name => this.admits(scope, name)).values())
}
}
Alternatives considered
Per-scope registry instances behind a parent/child delegation chain. Instance explosion; the "deployment tools plus my tools" merged view needs a hand-built delegating registry per service; single-subscription observers (persistence, the ACP bridge) would have to discover and subscribe per instance; and a delegation chain cannot express subtraction (restrictions). A child registry would also have to reach back into a parent context, widening the exposure surface.
Explicit scope parameters on registration APIs. Already rejected by the agent-scope RFC: omitting the parameter silently registers globally, and the shape can express visible-to-X-disposed-with-Y, which is almost always a bug.
Extracting only the data structure, leaving the choreography in services. Removes the safe half of the duplication and keeps the dangerous half — the rollback-before-emit ordering, raw-disposer, and reclamation rules are exactly where the bugs live.
A fixed-container helper with built-in view semantics. Pins container shapes and merge policy inside the helper; business gets no freedom, and every naming or single-value variation becomes a helper feature request.
One helper per table. Reproduces today's scattered bookkeeping — that is the status quo being replaced, with N scope maps per service and no aggregate for an agent's contribution.
helper.get(ctx).effect(...) two-step registration. Splits layer creation from lifecycle attachment; a throw between the steps strands an empty layer, and the returned handle is an extra allocation per call.
Layers holding a ctx and registering their own effects. Turns data objects into lifecycle managers and reinstates the choreography once per business class.
Acceptance criteria
store.tsships indsh-scope(peer deps unchanged: cordis only; module-graph position unchanged) with per-file 100% coverage, including: layer bookkeeping and reclamation, all four action shapes, seal ordering, the throwing-change-listener rollback (the entry is rolled back and the duplicate check re-registers), failure reclamation of freshly created layers,label/silent/scopedOnlyoptions,createLayerconstruction, reserved table names, back-reference typing, andEntriesnamed/anonymous semantics.dsh-toolsanddsh-system-prompteach collapse to oneScopedLayers; all existing tests pass with only the declared duplicate-message assertion updates; every registration facade is a singleeffectcall and keeps returning the exact cordis effect disposer.- Behavior matches the old baseline per the equivalence statement above: two declared exceptions (unified messages; error order for multiply-invalid inputs), two unobservable differences (aggregate reclamation timing; snapshot read views), nothing else.
- Documentation lands in the same change:
dsh-scope/dsh-tools/dsh-system-promptREADMEs; on implementation this RFC moves toimplemented/and the runtime-design RFC's registration section is updated in place.
Risks
- The layer/facade boundary may not fit a future consumer's shape. Mitigation: the bare
ScopeLayerinterface remains the floor, and wideningLayerClassto accept a factory (for layers with constructor dependencies) is a recorded non-breaking extension. createLayer's mapped-type factory is deliberate type gymnastics. Accepted: thedefineToolschema DSL is the repo precedent, and the gymnastics stay insidedsh-scope.- The two equivalence exceptions can surprise tests that assert exact duplicate messages or multi-fault error order; they are declared here so review checks them rather than discovers them.
- Snapshot read views hide entries registered by a callback during its own iteration — a pathological pattern, but a visible one; snapshots make it deterministic instead.
- Two core registries migrate at once. Mitigated by the behavior comparison performed during design and by landing the store with equivalence-pinning tests before either migration commit.