Skip to content

feat(mutation-lock): expose the reentrancy scope, and add a null control over verified evidence #180

Description

@drewstone

What is missing

Two unrelated asks against the same module boundary.

1. The mutation lock's reentrancy scope is unobservable, so an external writer cannot join it. withKnowledgeMutation is exported (src/index.ts:49) and is reentrant: at src/mutation-lock.ts:104-113 it reads activeRoots.getStore() and, when the current async context already holds the root, runs the callback inline rather than deadlocking. But activeRoots is a module-private AsyncLocalStorage (src/mutation-lock.ts:42). A consumer that must take the same store lock from its own wrapper — to batch its own writes around a read-modify-write — has no way to enter or observe that scope, so calling any lock-taking function from this package INSIDE its wrapper self-blocks against a lock the consumer already holds. The consumer's only option is to hold the lock by a private path and then forbid itself from calling this package while holding it.

Ask: export the scope. isKnowledgeMutationHeld(root): boolean, or an enterKnowledgeMutationScope(root, fn) that marks the context as holding a lock the caller took, so a nested withKnowledgeMutation on the same root runs inline instead of blocking. Either makes the reentrancy composable rather than internal.

A second, smaller detail with a measured basis: the default stale window is 900 s (DEFAULT_STALE_MS = 15 * 60 * 1000 at src/mutation-lock.ts:18, applied at :303 and :453), chosen for a knowledge-improvement run that holds the lock for minutes. Measured downstream, real holds are milliseconds — one page costs 9 ms end to end, a 50-page batch holds for 586 ms, and the largest store's longest real promotion hold is about 390 ms. A 900 s stale window bounds a crash wedge to a quarter of an hour of waiting. Make it a documented option with a stated relationship to the heartbeat interval (a lock-taker heartbeats at stale/3), so a consumer with millisecond holds can pick a window without guessing which side of the race it lands on.

2. A null control over verified evidence. Given a claim whose check passed, does the pass depend on the evidence? Re-run the check against an EMPTY workspace, with the run's hard-coded paths rewritten to that empty directory in the check text AND in the text of every script the check names, and only those scripts copied in at their relative path. A check that still passes produced its expected output with no evidence artifact — a constant emitter, a pure computation, or a read that reaches outside the run. That count is the number any self-certification guard has to beat before it may refuse anything.

The path rewriting is not a detail. Measured 2026-08-28 over 300 sampled claims of 1,991: 285 reproduced; an environment-variable-only null with no path rewriting passed 102, of which 100 read real evidence through a hard-coded path — that null measured the path habit, not self-certification. With paths rewritten and named scripts copied and rewritten too, the genuine evidence-independent count was 1.

Where it lives today

  • discovery-lab/tools/kb-write.mjs 211 lines. Its header at :55-60 states the constraint verbatim: "reentrancy — per async context, so a locked read-modify-write may call record() inside it. Upstream's reentrancy uses a private AsyncLocalStorage we cannot see, so calling an upstream lock-taking function INSIDE withKbMutation on the same root self-blocks. Do not do that; take the lock around Lab writes only." The file re-implements the lock over the SAME lockfile path (kbLockPath at :111 resolves to <root>/.agent-knowledge/mutation.lock.durable) with its own AsyncLocalStorage at :109.
  • discovery-lab/tools/null-control.mjs 255 lines.

Where it belongs

  • src/mutation-lock.ts:102export async function withKnowledgeMutation, and the scope at :42.
  • src/claim-evidence.ts:265export async function verifyGradeableEvidence(, which already parses, executes, and grades evidence with an explicit cwd and env. A null arm is the same call against an empty root with the check text rewritten.

Measured motive

Stated above per part: 100 of 102 apparent evidence-independent passes were an artifact of the naive null; the genuine count was 1 of 300. For the lock, the stale-window numbers are 9 ms, 586 ms, and 390 ms measured holds against a 900 s default.

Acceptance

kb-write.mjs can be deleted when a test takes the store lock through an exported entry point, calls a lock-taking function from this package inside it on the same root, and asserts the inner call runs inline rather than blocking. null-control.mjs can be deleted when an exported null-control API grades a sampled verified claim against an empty workspace with the check text and its named scripts rewritten, and reports the evidence-independent count.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions