Scope: Why a Live Agent Is the Key of Its Own Registration (Part 3)
Series: Inside the DeepSeek Harness — Part 3 of 16
Part 2 covered how the five patch layers resolve into one flat list of plugin rows before boot. That answers “what does the whole process look like.” It doesn’t answer a narrower, equally important question: once that process is running, how does one specific agent — inside a session where a dozen others might also be running — get a different persona, a narrower tool set, or a private sandbox mode, without anyone writing a new config file for it?
The answer is a primitive called scope, and it rests on a design choice that’s genuinely unusual: a live Agent object is the key of its own registration scope. Not a string id. Not a config row. The runtime object itself.
The key is opaque, on purpose
packages/core/scope/src/index.ts:15 declares the type as narrowly as it can be declared:
export type ScopeKey = object
That’s the whole type. Any object can be a scope key — the scope primitive doesn’t know or care what an Agent is. It just needs an identity to compare other things against, by reference. The shipped agent loop happens to use the live Agent instance as that identity. Nothing forces it to.
Figure: an opaque object identity, compared only by reference — the scope primitive never inspects what it's keyed to.
Reading agent.ctx gets you the agent’s own scoped Context. Registering something through agent.ctx — a tool, a prompt section, a variable, a restriction — does two things at once, and the codebase is careful to spell out that they’re two things, not one: it scopes the effect (the registration’s teardown is tied to that agent’s lifetime) and it scopes the dispatch (events about that agent’s activity carry its scope as a filter carrier). A comment on AgentRegistry.register() (packages/core/agent/src/index.ts:438) makes the distinction explicit: calling through agent.ctx scopes effects, but dispatch scoping always requires passing the carrier separately. One fact — “this belongs to Agent A” — drives two independent mechanisms.
Shadowing is just a Map, built in the right order
Here’s the part that’s genuinely elegant once you see it. “A scoped tool replaces its same-named global twin, for that scope alone” sounds like it needs special-case logic — check the scope first, fall back to global if absent, handle the override explicitly. It doesn’t. The real merge function, ScopedLayers.merge() in packages/core/scope/src/store.ts (class at line 159, method around line 205), is close to this:
// packages/core/scope/src/store.ts (abridged)
merge<V>(scope, pick): Map<string, V> {
const merged = new Map(pick(this.global).entries()) // seed with global
for (const layer of this.chainLayers(scope)) { // farthest ancestor -> nearest
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
}
return merged
}
That’s the entire shadowing mechanism. Seed a Map with every global registration. Then walk the scope’s ancestor chain from farthest to nearest, and for each layer, .set() every entry it has under the same key. Map.set() on an existing key overwrites the value — it doesn’t append, it doesn’t merge, it replaces. “Nearest scope wins” isn’t a rule the code enforces; it’s what naturally falls out of iterating outer-to-inner and letting a plain Map do what a plain Map always does.
Figure: shadowing as an emergent property of Map.set() order, not a special case in the merge logic.
There are two separate read primitives worth knowing apart, because they answer different questions: peek(scope) is “deliberately chain-blind” — it returns only that exact scope’s own contributions, used when you need to know what a scope itself registered (its own restrictions, its own guards). chainLayers() walks the full ancestor chain — used for inheritance-aware reads like merge(). One question is “what does this scope own”; the other is “what does this scope see.” Conflating them would be a real bug class, so the store keeps them as two different methods rather than one method with a boolean flag.
Restriction is the opposite shape: an intersection, not an overlay
Shadowing replaces one entry. A restriction (tools.restrict) does something structurally different: it filters the set of tools a scope inherits, and multiple restrictions compose by intersection. Critically, per the tool subsystem docs, a scope’s own registrations are exempt from its own restriction — “so a delegated child keeps the tools it answers through.” A restriction only ever narrows what flows down from ancestors; it can’t take away something the scope registered for itself.
Figure: restriction narrows what a scope inherits; it never touches what the scope registered for itself.
That last property — a restricted-away tool is absent from the prompt and refuses execution, not just hidden from the model’s menu — matters for Part 4: it means restriction isn’t a prompt-engineering trick, it’s enforced at the same pipeline stage that actually dispatches the tool body.
The setup window: register, don’t drive
Creating a scoped world for a new agent happens in a specific slot documented directly in the type signature. CreateAgentOptions.setup (packages/core/agent/src/index.ts:132, with an identical twin on ResumeAgentOptions:155 for resumed sessions) is an optional callback that runs after the agent’s scoped context (agentCtx) is minted, but before either the agent or its session is announced — before agent/session-start fires, before the first prompt gets assembled.
Figure: the setup window — a trusted, composition-only slot between minting a scope and publishing the agent it belongs to.
Both variants share the same contract, stated directly in the JSDoc: “all registrations exist before either creation announcement, and rejection, commit failure, or owner disposal rolls the transaction back without publishing either id.” That’s a real transactional guarantee — a creator can register a private persona, a narrower tool set, an isolated service realm, and if any of it fails partway through, nothing gets published at all. There’s no state where another part of the system observes a half-configured agent.
Lineage is data; scope is a separate graph
The last piece is the one that prevents scope from becoming an accidental inheritance hierarchy for subagents. Scope’s only parent/child machinery is three functions — bindScopeParent(key, parent) (index.ts:72), scopeParentOf(key) (:89), scopeChainOf(key) (:98) — and they exist purely to build the ancestor chain that merge() walks. Nothing calls them automatically when a subagent spawns. A subagent gets its own fresh ScopeKey — its own Agent object — and by default it is not bound into its creator’s scope chain at all. It sees only the global layer, unless something explicitly wires bindScopeParent.
Figure: two graphs over the same agents, deliberately non-isomorphic. Who spawned whom is not who inherits from whom.
This is worth sitting with, because it’s a real design decision with a real consequence: a subagent that inherited its parent’s scoped tool overrides by default would make delegation unpredictable — you’d have to trace the whole spawn chain to know what a given subagent can see. Keeping lineage as inert data, and scope as an independently-wired graph, means a subagent’s capability set is always knowable by looking at exactly one thing: what was registered into its scope, and what’s global. Nothing implicit rides along for the ride.
Coming up in this series
(All 16 parts are live today — no daily drip.)
| Part | Title | What it covers |
|---|---|---|
| 1 | DeepSeek Harness: Inside the Open-Source Claude Code Rival | The launch, the comparison, no privileged core, the four Cordis primitives |
| 2 | Composing an App From YAML, Not Code | Profiles, bundles, five patch layers, boot, live HMR, --dump-config |
| 3 | Scope: Why a Live Agent Is the Key of Its Own Registration (this post) | Shadowing, restriction, lineage vs. scope |
| 4 | Tool Execution in DeepSeek Harness: Guards and Approval | Pre-execute, monotonic guards, post-execute, approval |
| 16 | DeepSeek Harness: Agent Presets as Data, Not Code | Agent presets, PTC/Code Mode, the plugin ecosystem, compared to Claude Code’s agent types |
| 5 | Capability Seams: Making Bash Swappable for Sandboxed Bash | The 3-role pattern end-to-end, across ~85 real seams |
| 6 | The Session Log: DeepSeek Harness’s Enforced Invariant | The real invariant-checking code, surface projection |
| 7 | Persistence and Compaction: Crash-Safe by Construction | JSONL/SQLite, torn-tail repair, the compaction lock bracket |
| 8 | Waterfalls: The One Event Pattern That Runs Everything | Five dispatch modes, durable vs. live events, retry |
| 9 | The Agent Loop: Turns, Steps, and a Real Cancellation Bug | The phase state machine, the inbox, a dated bug fix |
| 10 | The LLM Layer: One Message Format, Every Surface | Message vocabulary, streaming, retry-via-log-replay |
| 11 | Subagents and Workflows: Composing Agents From Agents | Provider kinds, continuable children, Ralph rounds |
| 12 | Defense in Depth: Sandboxing and Four Real Incidents | bwrap/Landlock/Seatbelt/ACL, real production postmortems |
| 13 | Three Surfaces, One Spine: Web, Typert RPC, and SDK/ACP | Typert codegen, the four-quadrant RPC envelope |
| 14 | Engineering Rigor: DeepSeek Harness’s Verification Gate | The 100% coverage gate, real engineering-culture quotes |
| 15 | What a Second Production Harness Teaches | dsh vs. the Agent Harness series, side by side |
Part 16 shipped after this series’ initial 15 posts, once Agent Presets and Code Mode landed — it reads best right after Part 4, which is why its row sits there instead of at the end.
Next: Part 4 picks up exactly where restriction left off — once a tool call is actually dispatched, what stands between the model deciding to call bash and the command actually running?
References
- Source citations pulled directly from the repo as of commit
47f9438(master, Aug 13, 2026 — same commit cited throughout this series):packages/core/scope/src/index.ts(ScopeKey,createScope,scopeOf,scopeTarget,bindScopeParent),packages/core/scope/src/store.ts(ScopedLayers.merge,peek,chainLayers),packages/core/agent/src/index.ts(CreateAgentOptions.setup,ResumeAgentOptions.setup,AgentRegistry.register) docs/subsystems/scope.mdanddocs/subsystems/tools.md, deepseek-ai/deepseek-harnessdocs/glossary.md— canonical one-line definitions of scope, scope key, shadowing, restriction, setup window, lineage- Part 1 and Part 2 of this series
Have thoughts on this?
I read every email. If something resonated, felt wrong, or made you think — I'd love to hear from you.
Comments