Follow-up to the instruction-debugger spec (docs/superpowers/specs/2026-05-17-instruction-debugger-design.md, landed in #74 / v6.3.0). Tracks closing the residual graph-walk escape hatch from the spec's "Lockdown scope and residual escape hatches" section.
Background
v6.3.0 ships a per-State debug-config lockdown built around Object.defineProperty, not the Proxy approach the spec originally outlined. (The Proxy approach was attempted during implementation and abandoned — engine utilities like State.toGraph(arg, ...) read TS-downleveled private fields directly off their argument via __classPrivateFieldGet, which fails on a Proxy. See the pivot commit b19c7a6 and the v6.3.0 CHANGELOG entry.)
The shipped mechanism:
packages/machine/src/lockdown.ts exports installStateLockdown(state, onUserWrite) and installHaltLockdown(haltState). Both replace the engine's prototype debug accessor with an instance-level Object.defineProperty that delegates to the engine's prototype setter inside withLockdownEscape (used by PostMachine's internal #refreshStateDebug / #refreshHaltDebug), and routes user writes to a redirect handler (instruction States) or throws (haltState).
- At the end of
PostMachine construction, the constructor iterates #stateToCandidatePaths.keys() and installs the lockdown on every non-halt State in that map.
#stateToCandidatePaths only records states that map to a user-addressable instruction Path: top-level instructions, subroutine-body instructions (including nested), group-internal commands. Intermediate engine-graph states are not recorded and not locked:
- continuation states (
callerName~targetName after a group)
- subroutine-entry hopper states (the
state.name = subName dispatcher with a single ifOtherSymbol → body-reference transition)
- group-wrapper composite states emitted by
withOverrodeHaltState (outer>inner~next)
Problem
A user who walks the graph from an addressable State can reach an intermediate State and write to its debug field, bypassing the lockdown:
const downstream = pm.stateAt('10').getNextStateForSymbol(sym);
// downstream may be an intermediate state (e.g., a continuation, hopper, or
// group wrapper) that has no defineProperty lockdown installed.
downstream.debug = { before: true };
// Engine pauses on every visit; PostMachine's onPause wrapper sees no
// registered breakpoint and no matching candidate path → silent resume.
// The pause never reaches the user callback. Effectively a no-op write, but
// the engine still does the work to detect-and-skip every iteration.
For addressable downstream States (another instruction's State reached via graph walk), the lockdown IS installed at construction, so the write redirects correctly. The escape is specifically for intermediate, non-user-addressable states.
Proposal
Extend the install loop to also lock intermediate states. Two viable approaches:
-
Eager walk at construction. After #buildInitialState finishes, do a BFS/DFS from #initialState collecting every reachable State (resolving References). Install the lockdown on each one. For intermediate States, the redirect handler can simply throw with an explanatory message (no Path to redirect to) — these states aren't user-addressable, so direct .debug writes are always erroneous.
-
Lazy lock on first method-mediated access. Intercept State-returning surface (e.g., wrap getNextStateForSymbol via the lockdown installer) and install on the returned State before handing it back. Avoids walking the full graph at construction but adds per-call overhead and requires care around withOverrodeHaltState's shared-debugRef semantics.
(1) is simpler, has a single install pass, and keeps runtime overhead at zero. (2) avoids the BFS cost but adds complexity. Prefer (1) unless the eager walk shows up in profiles for large programs.
Scope
In scope:
- Collect every reachable
State from #initialState after construction (resolving References; deduplicating).
- Install the lockdown on each previously-unlocked State with a redirect handler that throws (with a message pointing at
pm.setBreakpoint for instruction-addressable States and explaining that intermediate states aren't user-addressable for the rest).
- Update
lockdown.ts if the per-State install needs to accommodate intermediate-State semantics (no candidate paths → must always throw, never redirect).
- Tests: walk via
getNextStateForSymbol to a continuation / hopper / group-wrapper, verify the .debug write throws.
- Update the "Subtleties worth knowing" entry in
CLAUDE.md and the "Lockdown semantics" section in packages/machine/README.md to drop the deferred-escape language.
Out of scope:
- Runtime channel inside callbacks (
m.state.debug = X). v6.3.0 already routes that through the lockdown's redirect handler — intermediate states in that path will hit the new throw, which is a behavior change worth flagging in the v7 CHANGELOG.
- Engine-level changes — the engine's
State class is unchanged.
- Performance tuning beyond the BFS walk's natural cost; if it shows up as a hotspot for large state graphs, file a separate perf issue.
Considerations
- Reference resolution. Graph edges go through
Reference objects (forward declarations). The BFS needs to call .ref to materialize the target State. References that point at the same underlying State must be deduplicated (use a Set<State>).
withOverrodeHaltState shared-debugRef. Engine v6 shares the _State_debugRef private field across wrapped states constructed by withOverrodeHaltState. The lockdown's escape delegates to the engine's prototype setter, which respects this — so an internal write through one wrapper propagates correctly. Verify behavior for group-wrapper composites that share debug refs with their wrapped target.
- Engine surface stability. If a future engine release adds a new State-returning method or field on
State, the BFS may miss those edges. Worth a peer-dep-aware test that exercises every documented State edge.
- Halt singleton. Already locked module-globally via
installHaltLockdown in src/index.ts; the BFS should skip state.isHalt States (no double-install).
Acceptance
Likely landing
v7 territory. v7 is the lockstep release that picks up the upstream engine's v7 naming change (paren-based composite naming A(B), which forbids (, ), possibly > in user-provided names). Bundling this hardening pass with that release keeps debugger-surface work concentrated in one major, rather than spreading it across v7 + v8.
Companion items for the same release (all from the spec's "Out of scope" section):
- Conditional-breakpoint
when predicate (spec link).
- PostMachine's
BreakpointFilter shape diverging from the engine's DebugConfig.
- Reform of how
MachineState.state is exposed in callbacks (if we ever revisit the runtime-channel decision).
The graph-walk lockdown is mechanically independent of those — it can land standalone in a v6.x point release if v6.3.0 receives consumer feedback that the escape is being hit in practice.
Cross-references
Follow-up to the instruction-debugger spec (
docs/superpowers/specs/2026-05-17-instruction-debugger-design.md, landed in #74 / v6.3.0). Tracks closing the residual graph-walk escape hatch from the spec's "Lockdown scope and residual escape hatches" section.Background
v6.3.0 ships a per-State debug-config lockdown built around
Object.defineProperty, not theProxyapproach the spec originally outlined. (The Proxy approach was attempted during implementation and abandoned — engine utilities likeState.toGraph(arg, ...)read TS-downleveled private fields directly off their argument via__classPrivateFieldGet, which fails on a Proxy. See the pivot commitb19c7a6and the v6.3.0 CHANGELOG entry.)The shipped mechanism:
packages/machine/src/lockdown.tsexportsinstallStateLockdown(state, onUserWrite)andinstallHaltLockdown(haltState). Both replace the engine's prototypedebugaccessor with an instance-levelObject.definePropertythat delegates to the engine's prototype setter insidewithLockdownEscape(used by PostMachine's internal#refreshStateDebug/#refreshHaltDebug), and routes user writes to a redirect handler (instruction States) or throws (haltState).PostMachineconstruction, the constructor iterates#stateToCandidatePaths.keys()and installs the lockdown on every non-halt State in that map.#stateToCandidatePathsonly records states that map to a user-addressable instruction Path: top-level instructions, subroutine-body instructions (including nested), group-internal commands. Intermediate engine-graph states are not recorded and not locked:callerName~targetNameafter a group)state.name = subNamedispatcher with a singleifOtherSymbol→ body-reference transition)withOverrodeHaltState(outer>inner~next)Problem
A user who walks the graph from an addressable State can reach an intermediate State and write to its
debugfield, bypassing the lockdown:For addressable downstream States (another instruction's State reached via graph walk), the lockdown IS installed at construction, so the write redirects correctly. The escape is specifically for intermediate, non-user-addressable states.
Proposal
Extend the install loop to also lock intermediate states. Two viable approaches:
Eager walk at construction. After
#buildInitialStatefinishes, do a BFS/DFS from#initialStatecollecting every reachableState(resolvingReferences). Install the lockdown on each one. For intermediate States, the redirect handler can simply throw with an explanatory message (no Path to redirect to) — these states aren't user-addressable, so direct.debugwrites are always erroneous.Lazy lock on first method-mediated access. Intercept State-returning surface (e.g., wrap
getNextStateForSymbolvia the lockdown installer) and install on the returned State before handing it back. Avoids walking the full graph at construction but adds per-call overhead and requires care aroundwithOverrodeHaltState's shared-debugRef semantics.(1) is simpler, has a single install pass, and keeps runtime overhead at zero. (2) avoids the BFS cost but adds complexity. Prefer (1) unless the eager walk shows up in profiles for large programs.
Scope
In scope:
Statefrom#initialStateafter construction (resolvingReferences; deduplicating).pm.setBreakpointfor instruction-addressable States and explaining that intermediate states aren't user-addressable for the rest).lockdown.tsif the per-State install needs to accommodate intermediate-State semantics (no candidate paths → must always throw, never redirect).getNextStateForSymbolto a continuation / hopper / group-wrapper, verify the.debugwrite throws.CLAUDE.mdand the "Lockdown semantics" section inpackages/machine/README.mdto drop the deferred-escape language.Out of scope:
m.state.debug = X). v6.3.0 already routes that through the lockdown's redirect handler — intermediate states in that path will hit the new throw, which is a behavior change worth flagging in the v7 CHANGELOG.Stateclass is unchanged.Considerations
Referenceobjects (forward declarations). The BFS needs to call.refto materialize the target State. References that point at the same underlying State must be deduplicated (use aSet<State>).withOverrodeHaltStateshared-debugRef. Engine v6 shares the_State_debugRefprivate field across wrapped states constructed bywithOverrodeHaltState. The lockdown's escape delegates to the engine's prototype setter, which respects this — so an internal write through one wrapper propagates correctly. Verify behavior for group-wrapper composites that share debug refs with their wrapped target.State, the BFS may miss those edges. Worth a peer-dep-aware test that exercises every documented State edge.installHaltLockdowninsrc/index.ts; the BFS should skipstate.isHaltStates (no double-install).Acceptance
pm.stateAt(...).getNextStateForSymbol(sym)to a continuation / hopper / group-wrapper State; direct.debug = ...write throws with an instructional error..debug.before = ...chained writes (covered automatically by the engine's existing DebugConfig getter-only access, since the prototype-delegating accessor doesn't touch DebugConfig itself).pm.initialStatecovers continuations, hoppers, group wrappers; noStatereachable in the graph remains un-locked.instanceof State, engine utilities (State.toGraph,summarize,toMermaid) keep working on every reachable State (no Proxy regression).CLAUDE.mdSubtlety Disallow to use external functions as commands #6 andpackages/machine/README.mdlockdown notes updated to drop the graph-walk deferred-escape language.Likely landing
v7 territory. v7 is the lockstep release that picks up the upstream engine's v7 naming change (paren-based composite naming
A(B), which forbids(,), possibly>in user-provided names). Bundling this hardening pass with that release keeps debugger-surface work concentrated in one major, rather than spreading it across v7 + v8.Companion items for the same release (all from the spec's "Out of scope" section):
whenpredicate (spec link).BreakpointFiltershape diverging from the engine'sDebugConfig.MachineState.stateis exposed in callbacks (if we ever revisit the runtime-channel decision).The graph-walk lockdown is mechanically independent of those — it can land standalone in a v6.x point release if v6.3.0 receives consumer feedback that the escape is being hit in practice.
Cross-references
docs/superpowers/specs/2026-05-17-instruction-debugger-design.md.37fcfee.b19c7a6in PR Per-instruction breakpoints + path-based resolver + lockdown (#59, #63) #74.packages/machine/CHANGELOG.md.