diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ff1a9d..9f2f494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,94 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **The artifact verbs and the read that writes — the safe core is complete.** Step 4.3 + of the ratified write-tools design: `artifact.add`, `artifact.close`, + `artifact.retract` and `score.aperture` complete the ten-tool `--write` roster, each a + thin boundary over the same domain mutation the CLI runs — `artifact add`/`retract` + split into transaction-shaped cores (`src/artifacts.js`), the closure gate moved there + from the CLI router, and the aperture fog write moved to `src/verbs.js`, all shared by + both doors. The lifecycle gates the CLI earned hold unchanged on the wire + (**MCP-boundary enforced** where the rule is static, domain-refused where it needs the + record): terminal statuses and reserved lifecycle fields refuse at the boundary; an + identical revision is a no-op that costs no revision and invalidates no proof; a close + is earned only by a KEEP bound to the exact revision and hash, inside one transaction + that spans the journal lock, commit included; and a probe exit must state + `disposed:`/`promoted:` with a recorded non-probe replacement. **There are no waiver + arguments on this wire, permanently**: record-scope proof and holes-waived closure + refuse `HumanAuthorityRequired` — typed self-authorization is not a named human, so + those closures stay CLI acts. Four new allowlisted refusals (`ArtifactClosed`, + `ClosureBlocked`, `HumanAuthorityRequired`, `RetractRefused`) join the funnel; raw + domain messages, which may name store files, never cross. `score.aperture` names + `expectedStateRev`/`expectedStateGen` like every write — the fog write's + first-racer-wins guard legitimately re-arms when a map lands, so CAS, not an + idempotence claim, is what keeps a stale retry out; a score that owes no fog is + byte-pure and commits nothing, and `recordedFog` is truthful on both outcomes. Six new + falsifiers in `test/mcp-write.test.js` (contract pins, CLI-equivalence per verb, + byte-pure refusals, verbatim replay of a closure certificate); the refusal mapping, the + identical-revision no-op, and the one-fog-write guard were each seen red against a + deliberately broken variant. + + + +- **The five MCP session verbs — the roster rides the proven envelope.** Step 4.2 of the + ratified write-tools design: `state.append`, `open_loop.close`, `open_loop.park`, + `assumption.close` and `compile.done` join `state.set` on a `--write` server, each a + thin boundary over the same domain mutation the CLI verb runs — the meanings of + `state append`, `state close` and `compile done` moved to `src/verbs.js`, shared by + both doors, so the two surfaces cannot drift into two meanings for one verb. Every + tool carries the full 4.1 envelope (revision + generation CAS, operation receipts, + deterministic ids, one error funnel) through one shared outcome mapping. CLI gates + travel to the wire as schema (**MCP-boundary enforced**): evidence, owner, + revisit-trigger and the tested|killed outcome are required non-empty fields; the + `collection` enum excludes `artifacts` and `defects`, whose gated constructors a raw + append would bypass; and a claimed non-birth status never crosses the boundary — loops + are born `open`, assumptions `untested`, on both doors. Transitions on records that do + not exist refuse the new allowlisted `UnknownRecordId` with zero bytes moved; a + same-text loop or assumption dedups under the lock as a no-op naming the existing + record. `state.append` is the one non-destructive hint in the roster — a status + transition or checkpoint overwrite is not additive merely because provenance survives. + Twelve new falsifiers in `test/mcp-write.test.js` (roster contracts, CLI-equivalence + per verb, byte-pure refusals, a replayed checkpoint stamp), each seen red before the + roster existed; the `UnknownRecordId` mapping and the birth-status boundary check were + additionally each seen red against a deliberately broken variant. Cross-file verbs + still wait for 4b. + + + +- **The first MCP write tool — and the machinery that makes a retried write safe to + retry.** `state.set` joins the registry ONLY when the server is launched with `--write`: + an unflagged server registers no write tools at all, so `tools/list` never advertises + capability the operator did not grant, and `--write` under a propose-only + `RATCHET_AGENT` refuses at startup instead of failing every call (**CLI-enforced**, + with `assertMayWrite` still the backstop underneath). Every write names + `expectedStateRev` AND `expectedStateGen` — the revision and store lineage it decided + against — and is refused stale, never merged; a recreated store that reuses a numeric + revision still refuses on the generation. Every write carries an `operationId` whose + receipt is durable **inside the state record** (`state.operations`, ring of 32, + committed in the same atomic rename as the revision it describes), so a verbatim retry + — same connection or a fresh one after a server crash, new handle and all — returns the + persisted result marked `replayed` instead of applying twice; the same id with a + different meaning refuses `OperationIdConflict`. The binding hashes tool + semantic + arguments + revision + generation, never the handle: transports die, decisions don't. + Ids minted by an MCP write derive from that binding, so a crash-boundary re-application + converges on the same record — and a derived id that already names a record refuses + `DeterministicIdConflict` rather than letting entropy impersonate an address. A no-op + commits nothing and records no receipt (the 0.9 property, kept on purpose; its + observability limit is stated in the spec). All refusals cross one funnel with an + allowlisted sentence each — no path, errno, or store location rides the wire — and + every structured result, success and refusal alike, conforms to the declared + `oneOf` output schema (**MCP-boundary enforced**). Five crash-boundary replay tests, + each seen red against a deliberately broken variant, include a real child process dying + at the commit rename and a real reconnect over `bin/ratchet-mcp --write`. The verb's + meaning moved to `src/verbs.js`, shared by the CLI and the MCP boundary — one + implementation, two doors. `workspace.open` now reports `stateGen` beside `stateRev`, + from the same snapshot. Safe core per the ratified step-4 design + (docs/superpowers/specs/2026-07-31-mcp-write-tools-design.md): cross-file verbs + (`defect.*`, `ledger.update`) wait for 4b's WAL design; waivers stay CLI acts, never + wire arguments. + + + - **The 16 canonical ratchet prompts are now an MCP prompt surface.** `prompts/list` advertises each prompt and its body-derived required arguments; `prompts/get` substitutes those arguments into a fresh copy of the canonical body for both supported protocol eras. diff --git a/docs/superpowers/specs/2026-07-31-mcp-write-tools-design.md b/docs/superpowers/specs/2026-07-31-mcp-write-tools-design.md new file mode 100644 index 0000000..fc40fbd --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-mcp-write-tools-design.md @@ -0,0 +1,457 @@ +# MCP Step 4: Write Tools — safe core + +Date: 2026-07-31 (rev 3 — rev 2 verified and tightened after the follow-up review; +rev 1's three premise-level failures are named inline where their fixes live) +Base: `main` at `8c7d3fa` +Branch (proposed): `feat/mcp-write-tools` +Status: RATIFIED — D1–D4 approved by Danny 2026-07-31. Build in progress: +4.1 (envelope on the state.set canary + five crash-boundary replay tests) landed on +`feat/mcp-write-tools`; 4.2–4.4 follow. + +## Objective + +Expose the single-file (state-record) write lifecycle over MCP with the two guarantees +the ratified build order names for step 4: + +1. `expectedStateRev` and `expectedStateGen` are REQUIRED on every write — a write must + name both the revision and the store generation it decided against or it is refused, + not merged. No exemptions: `score.aperture` writes fog, so it names both. +2. `operationId` binds to method + semantic-argument hash, `OperationIdConflict` is a + distinct refusal, and five crash-boundary replay tests prove a retried write can never + apply twice, never claim an outcome it does not have, and survives a server restart. + +**Cross-file verbs are OUT of step 4.** `defect.add/resolve/reopen/supersede` and +`ledger.update` write two canonical files that cannot commit atomically, and rev 1's +"secondary first, receipt last" ordering could strand the pair permanently: ledger +written, process dies, the retry never comes — or arrives after another writer moved the +revision and refuses stale — and the two files disagree forever with nothing scheduled to +reconcile them. That family waits for step 4b, whose named route is a write-ahead intent +record with mandatory recovery before any writer proceeds (review approach 2). Deferring +is honest; shipping a consistency claim the crash windows contradict is not. + +## Why CAS alone is not enough — the operationId rationale + +`withWorkspaceMutation` already gives at-most-once semantics for a named revision: a write +that committed rev N→N+1 makes any blind retry with `expectedStateRev: N` refuse stale. So +double-apply is already impossible. What CAS cannot answer is the question a client holds +after a lost response or a server crash: **did MY write land, or did someone else's?** +Both look identical from outside — a stale refusal. For a committed operation, the +receipt answers *which*: a retry either returns the recorded outcome (yours landed) or +applies cleanly (it never did). A no-op has no durable outcome and is the explicit +observability exception described below. `expectedStateRev` gives safety; `operationId` +gives committed-write crash-boundary observability. One mechanism, not two features. + +## Contract boundary — the roster + +Every tool is a thin MCP boundary over the same domain mutation the CLI verb runs. One +domain implementation, two boundaries; where cli.js holds verb logic inline, it is +extracted verb-by-verb (only the verbs step 4 ships), with cli.js delegating. + +| Tool | CLI verb | Writes | +| --- | --- | --- | +| `state.set` | `state set ` | state | +| `state.append` | `state append ` | state | +| `open_loop.close` | `state close openLoops --evidence` | state | +| `open_loop.park` | `state close openLoops --park --owner --revisit-trigger` | state | +| `assumption.close` | `state close assumptions --outcome --evidence` | state | +| `compile.done` | `compile done` | state | +| `artifact.add` | `artifact add ` | state | +| `artifact.close` | `artifact close ` — file-scope proof only, **no waiver arguments** | state | +| `artifact.retract` | `retract --reason [--superseded-by]` | state | +| `score.aperture` | `score aperture ` | state (conditional fog record) | + +Outer input objects are closed (`additionalProperties:false`). After removing the shared +envelope, the semantic arguments are: + +| Tool | Semantic arguments | +| --- | --- | +| `state.set` | `key` (the `STATE_SCALARS` enum), `value` string; the shared domain helper applies the CLI's existing coercion | +| `state.append` | `collection` (`decisions`, `assumptions`, `openLoops`, `touchedFiles`, or `history`), `item` object | +| `open_loop.close` | non-empty `id`, non-empty `evidence` | +| `open_loop.park` | non-empty `id`, `owner`, and `revisitTrigger` | +| `assumption.close` | non-empty `id`, `outcome: "tested" \| "killed"`, non-empty `evidence` | +| `compile.done` | none | +| `artifact.add` | `item` object accepted by the existing artifact constructor; reserved lifecycle fields remain refused there | +| `artifact.close` | non-empty `id` only | +| `artifact.retract` | non-empty `id`, non-empty `reason`, optional non-empty `supersededBy` | +| `score.aperture` | required integer `ambiguity`, `terrain`, `taste`, `blastRadius`, `reversibility`, each 0..2 | + +`open_loop.park.owner` assigns who carries an unresolved loop; it does not authorize a +waiver and the parked loop continues to drain confidence. That attribution field is not +treated as a human-approval token. + +Gates travel to the wire as schema: a verb whose CLI refuses without a flag has that flag +as a required schema field (**MCP-boundary enforced**). Domain gates (an artifact close +needs a KEEP bound to its revision) remain domain refusals. + +**Excluded, by rule:** + +- `defect.waive`, `artifact.close --waive-holes`, and record-scope artifact closure — + waivers are *human risk acceptance*, and `"owner": "Danny"` typed into a JSON field by + a wire client is self-authorization, the same reason `reset --force` has no MCP + spelling. MCP `artifact.close` therefore closes only file-scope artifacts with bound + proof and no holes; record-scope or holes-waived closure stays a CLI act. Permanent + exclusion, not a deferral. +- `init --force`, `state reset --force` — irreversible operator verbs (convention 8). +- `defect.*` (remaining four), `ledger.update` — deferred to 4b (cross-file, above). +- `ledger.create` — `workspace.open` is the initialization boundary (3b). +- `doctor` — diagnoses the installation, not an opened workspace (3b ruling stands). +- `touch` — `state.append` on `touchedFiles` is the same record; two spellings is drift. + +## The write envelope + +Every write tool shares one envelope alongside its verb arguments: + +```json +{ + "workspaceHandle": "", + "expectedStateRev": 12, + "expectedStateGen": "gen-mk8f4s-6cbd97f6e5b47120", + "operationId": "3f2a9c81-1463-4eef-b1b0-78efad6d2aa9", + "...verb arguments..." +} +``` + +- `workspaceHandle` — resolves through the same `resolveHandle` as every read, with + `authority.use(handle, "write")`. One authority check, one non-enumerating refusal. +- `expectedStateRev` — required non-negative integer on every tool in the roster. + Missing, negative, or non-integer refuses `-32602` at the boundary; the domain layer + never sees the call. +- `expectedStateGen` — required string copied from `workspace.open.stateGen` (and visible + in the state resource). The mutation boundary compares it under the workspace lock, + before the revision check. A mismatch is the new coded domain error + `ERATCHETSTALEGEN`, mapped to `StaleGeneration`, with zero bytes moved; it is not + overloaded onto revision code `ERATCHETSTALE`. +- `operationId` — required client-generated string, `[A-Za-z0-9_-]{22,128}`. Clients MUST + never reuse one for a different operation and SHOULD use a UUIDv4 or at least 128 bits + of random entropy; syntax validation cannot prove entropy. + +**The binding hash names the operation's meaning, not its transport.** `argsHash` = +SHA-256 over a canonical (key-sorted, deterministic) JSON encoding of +`{tool, semantic verb arguments, expectedStateRev, expectedStateGen}`. +`workspaceHandle` and `operationId` are EXCLUDED. Rev 1 hashed the full request including +the handle — a premise-level failure: handles are connection-scoped, receipts are durable +in the store, so the exact crash the receipt exists to survive (server dies, client +reconnects, new handle) made the intended retry hash differently and refuse as +`OperationIdConflict`. Binding to tool + semantic args + the client-observed revision and +generation is what makes a retry mean "the same decision about the same store," whatever +connection carries it. The server's *current* generation is not substituted into the +hash: doing that would not protect a request decided against generation A when an +out-of-band repair recreated generation B at the same numeric revision. The client must +carry the generation it observed. `expectedStateRev` stays load-bearing for eviction +safety (below); `expectedStateGen` closes generation aliasing honestly. + +Canonical encoding recursively sorts object keys by Unicode code point, preserves array +order, performs no Unicode normalization, and then applies `JSON.stringify`. Inputs have +already crossed JSON-RPC, so unsupported JavaScript-only values cannot enter the hash. +Golden vectors pin nested objects, arrays, empty values, and non-ASCII strings. + +## Operation receipts — durable atomically with the commit, or not at all + +The crash boundary is a two-file problem: a receipt written *after* the state commit can +be lost (crash between → retry double-applies); a receipt written *before* certifies a +write that never landed. Two files cannot rename atomically. So the receipt gets no file +of its own: **committed operations record their receipt inside the state record** — +`state.operations`, a bounded ring (cap 32, oldest evicted) of + +```json +{ + "id": "3f2a9c81-1463-4eef-b1b0-78efad6d2aa9", + "tool": "artifact.add", + "argsHash": "sha256:...", + "gen": "gen-mk8f4s-6cbd97f6e5b47120", + "rev": 13, + "at": "...", + "result": { + "ok": true, + "committed": true, + "stateRev": 13, + "replayed": false, + "artifactId": "art-..." + } +} +``` + +appended by the mutation boundary in the same `writeJson` that commits the revision. One +atomic rename carries both, so a receipt exists **iff** the write landed. The entry's key +is `id` — the operationId — so `mergeArray`'s id-based identity applies to the ring as it +does to every other collection. Schema addition to `schemas.js newState()`. + +**The persisted result is the replay's answer.** A replayed call must return a +structured result conforming to the tool's declared output schema — MCP requires +structured results to conform, and "I applied it earlier" is not a conforming shape — so +the receipt stores the full success envelope. Result envelopes are fixed-shape and small +by design (verdict fields + minted ids, never document bodies); the boundary enforces a +serialized cap per entry (4 KiB) before commit and refuses an oversized result with zero +bytes moved. Tests prove every designed result fits; runtime still fails closed instead +of truncating if a future tool violates the cap. + +**No-op writes record no receipt.** The 0.9 boundary property — an idempotent re-run +costs no revision — survives untouched: the receipt is appended only after the boundary's +before/after comparison says the verb moved the record. Safe because a no-op is +self-idempotent: retrying re-runs it and it no-ops again. Receipts protect the writes +whose second application would *not* be a no-op. + +That is a safety claim, not durable no-op observability. If a no-op response is lost and +the state is unchanged, retry returns the same no-op result. If another write moves the +revision first, retry refuses stale; it cannot replay the lost no-op result because none +was stored. A no-op operationId is likewise not retained or server-bound (although +clients still MUST NOT intentionally reuse operationIds). Tests pin both outcomes so the +committed-write guarantee cannot silently expand to no-ops later. + +**Replay semantics, under the workspace lock, in order:** + +1. Look up `operationId` in the ring. Found with matching `argsHash` → return a cloned + persisted result marked `"replayed": true`. The stored `replayed:false` bytes do not + change; zero bytes move. +2. Found with a DIFFERENT `argsHash` → `OperationIdConflict`. Zero bytes move. One id, + two meanings is a client defect and the server refuses to guess. +3. Not found → normal path: state-existence check, `expectedStateGen` check, + `expectedStateRev` check, mutate, validate the result envelope, commit with receipt. + +The server can enforce one-id/one-meaning only while the receipt is retained. After +eviction, a client that violates the MUST-NOT-reuse rule can present the same operationId +with a fresh revision and a different binding; the server accepts it as new because the +old binding is deliberately gone. Deterministic record ids therefore include the full +binding hash rather than operationId alone, so misuse after eviction cannot address the +old record. This is bounded idempotency-key retention, not a claim of eternal uniqueness. + +**Eviction and reset are safe by the same theorem.** A receipt evicts only after ≥32 +later commits, so the evicted operation's `expectedStateRev` is deeply stale and its +retry refuses. A reset wipes the ring with the record it lives in — and the revision line +CONTINUES across a wipe (`state.js:886`, deliberate: restarting revisions is exactly what +would let a pre-reset snapshot CAS-match again), so a post-reset retry lands on "not +found" and refuses stale. Monotonic revisions + ring-inside-the-record close those two +windows. A state file destroyed and recreated out-of-band can reuse a numeric revision, +so `expectedStateGen` is still required for that separate lineage case. Rev 1 was wrong +to justify generation pinning with reset; rev 3 retains the mechanism only for the case +that actually needs it. + +## Deterministic ids — replay must converge on the same record + +`makeId` is CSPRNG-random, so a re-applied mutation would mint different record ids on +each attempt. Every id a write tool mints derives from the operation's meaning: +`-`, where `role` +distinguishes multiple ids minted by one verb (a defect verb in 4b mints a defect id and +a history id from one operation). Same decision, same ids — re-application converges +instead of duplicating, and the id is stable across connections because nothing +connection-scoped is in the derivation. Thirty-two hexadecimal digits retain 128 bits of +the digest; the earlier 12-digit truncation retained only 48. CLI verbs keep `makeId`; +their boundary has no retry. + +Entropy is not permission to merge. On the normal not-found path, if a server-derived id +already names any record, the mutation refuses `DeterministicIdConflict` before changing +state; it never falls through to a domain path that interprets the collision as "revise +this artifact." A legitimate retained retry left through the receipt path earlier. An +injected-collision test pins the zero-byte refusal. + +## score.aperture — the read that writes, inherited from 3b + +Input: the five dimensions plus the full write envelope — `expectedStateRev` and +`expectedStateGen` required like every other write. Output: the common success envelope +plus the CLI `--json` projection (`score`, `level`, `name`, +`implement`, `sequence`, `mapRequired`, `dimensions`, `scope`, `recordedFog`) plus +`stateRev`. + +Rev 1 exempted aperture from CAS on the grounds that its fog write is first-racer-wins +idempotent (`fogAlreadyOnRecord`). The review broke the premise: that guard's state +legitimately changes — a fog loop closes when the unknown-map artifact lands — so an old +retry surviving receipt eviction would find the guard re-armed and record fog a second +time, a stale write wearing an idempotence claim. With `expectedStateRev` and +`expectedStateGen` required, the stale retry refuses like any other write, and the +eviction theorem covers aperture with no special case. The cost is honest and stated: +over MCP, an aperture score can refuse +stale because an unrelated write moved the revision; the client re-reads and re-scores — +the score was computed against a world that changed, and re-scoring is the correct +response, not a queue to engineer around. The CLI's lock-free double-check is a CLI +behavior and does not change. Annotations: `readOnlyHint: false`, +`destructiveHint: false`, `idempotentHint: true`. + +## Authority: the --write opt-in and the agent guard + +- **Write capability is declared at spawn, never inferred.** `bin/ratchet-mcp` gains a + `--write` flag (same discipline as roots: explicit or absent, flags beat env, no + fallback). Without it the server is what it is today — read-only, and the write tools + are simply NOT REGISTERED, so `tools/list` stays truthful rather than advertising + tools that refuse. With it, `workspace.open` mints its directory grant with + `operations: ['read', 'write', 'list']` and write tools cross `resolveHandle` with + `use(handle, 'write')`; verify-on-use (2.5) applies unchanged. +- **Propose-only agents keep their reads.** `RATCHET_AGENT` naming a propose-only role + is compatible with a read-only server and refused with `--write`: `main.js` exits 2 at + startup with a stderr diagnostic naming the conflict — a visible misconfiguration + beats a per-call mystery, and `assertMayWrite` remains the backstop underneath. + **CLI-enforced + tests proving both the refusal and the propose-only read path.** + +## Discovery and schemas + +- Registered write tools append to the one descriptor/handler registry in its advertised + order; the 3b invariant (listed ⇔ implemented) extends unchanged. +- `workspace.open` adds required `stateGen` beside `stateRev`, both taken from the same + loaded snapshot. A reconnect therefore gets a new handle but the same generation for a + surviving store, while a recreated store names its new lineage explicitly. +- **Every write tool declares an `outputSchema`, and every structured result conforms.** + Each schema is a root object with `oneOf` success and error branches (JSON Schema + 2020-12). Tool execution errors still set `isError: true`, but that flag does not exempt + a supplied `structuredContent` object from the tool's schema. Protocol-level `-32602` + responses are outside the tool result and therefore outside `outputSchema`. + +The common success branch is +`{ok:true, committed:boolean, stateRev:integer, replayed:boolean}` plus the fixed verb +projection below. It contains identifiers and verdicts, never caller document bodies. + +| Tool | Additional success fields | +| --- | --- | +| `state.set` | `key` | +| `state.append` | `collection`, `recordId`, `deduped` | +| `open_loop.close` | `openLoopId`, `status: "closed"` | +| `open_loop.park` | `openLoopId`, `status: "parked"` | +| `assumption.close` | `assumptionId`, `status: "tested" \| "killed"` | +| `compile.done` | `checkpointed: true`, `lastCompileAt` | +| `artifact.add` | `artifactId`, `artifactRev`, `action: "created" \| "revised" \| "unchanged"` | +| `artifact.close` | `artifactId`, `artifactRev`, `status: "closed"` | +| `artifact.retract` | `artifactId`, `status: "retracted"`, optional `supersededBy` | +| `score.aperture` | CLI JSON projection: `score`, `level`, `name`, `implement`, `sequence`, `mapRequired`, `dimensions`, `scope`, `recordedFog` | + +The shared error branch is +`{ok:false, error, message, expectedStateRev?, actualStateRev?, expectedStateGen?, +actualStateGen?}` with `additionalProperties:false`; both `actual` fields admit their +normal scalar type or `null`. Each known error code pins which optional fields are +required. If no state record exists, `StateNotInitialized` wins before generation or +revision comparison and returns both actual fields as `null`. Live success, replayed +success, and every structured error are validated in tests against the exact descriptor +served on the wire. + +Annotations are explicit per tool. `openWorldHint:false` and `idempotentHint:true` apply +to all ten; receipt/CAS semantics make an exact retry safe. `destructiveHint:false` is +reserved for additive writes. A status transition or overwrite is not called +non-destructive merely because provenance survives or a later build can supersede it. + +| Tools | `readOnlyHint` | `destructiveHint` | +| --- | --- | --- | +| `state.append`, `score.aperture` | `false` | `false` | +| `state.set`, `open_loop.close`, `open_loop.park`, `assumption.close`, `compile.done`, `artifact.add`, `artifact.close`, `artifact.retract` | `false` | `true` | + +## Error surface — one funnel + +All domain errors cross ONE mapping function (the `safeOpenError` pattern, extended: +`safeWriteError`), so no verb-specific catch can leak a filesystem path, an errno, or a +store location onto the wire. A test enumerates every write path and asserts the wire +text against an allowlist of sentences. + +| Condition | Where | Wire shape | +| --- | --- | --- | +| Write tool named on a flagless server | boundary | `-32602`, unknown tool (the tool is neither listed nor dispatchable) | +| Handle missing/malformed/stale/foreign on a `--write` server | boundary | `-32602`, the one non-enumerating message | +| Envelope malformed (`expectedStateRev`, `expectedStateGen`, `operationId`, verb args) | boundary | `-32602`, verb-specific message (reveals no authority) | +| State record absent | domain outcome | `isError` + conforming `{ "ok": false, "error": "StateNotInitialized", "actualStateRev": null, "actualStateGen": null, "message": "..." }` | +| Stale generation | domain outcome | `isError` + conforming `{ "ok": false, "error": "StaleGeneration", "expectedStateGen": g, "actualStateGen": h, "message": "..." }` | +| Stale revision | domain outcome | `isError` + conforming `{ "ok": false, "error": "StaleStateRev", "expectedStateRev": n, "actualStateRev": m, "message": "..." }` | +| Same operationId, different retained binding | domain outcome | `isError` + conforming `{ "ok": false, "error": "OperationIdConflict", "message": "..." }` | +| Server-derived id already names a non-identical record | domain outcome | `isError` + conforming `{ "ok": false, "error": "DeterministicIdConflict", "message": "..." }` | +| Verb refusal (unknown id, closed artifact, missing proof) | domain outcome | `isError` + conforming error branch, one mapped sentence, no server paths | + +Stale and conflict are domain outcomes, not protocol violations — they ride as tool +results so clients can read the structured refusal and re-read/re-decide; `-32602` stays +what it has been since 2.2: "your request never crossed the boundary." Refusals of every +kind move zero bytes — the 3b byte-purity proof extends over every refusal path. + +## Internal sequence (each lands reviewed before the next) + +- **4.1 Envelope on one canary verb.** The full mutation envelope — receipts ring with + persisted results, revision + generation CAS, binding hash, replay/conflict/eviction, + deterministic ids, `--write` opt-in, agent startup guard, grant widening, error funnel + — proven on `state.set` alone, with the five crash-boundary replay tests. Everything + load-bearing ships here; every later sub-step is roster, not mechanism. +- **4.2 Session verbs.** `state.append`, `open_loop.close/park`, `assumption.close`, + `compile.done`. +- **4.3 Artifact verbs + aperture.** `artifact.add/close/retract`, `score.aperture`. +- **4.4 Adversarial pass.** Replay races, conflict fuzzing, reconnect storms, + reset and out-of-band recreation mid-connection, authority (write-tool names against a + flagless server, foreign handles on every registered write tool, propose-only + + `--write`), refusal byte-purity, error-text allowlist, both protocol eras over the real + wire. +- **4b (separate step, WAL design first).** The defect family and `ledger.update`, with + durable pending intents and mandatory recovery before any writer proceeds. The + cross-file crash test lives there, where the machinery it tests exists. + +## The five crash-boundary replay tests (4.1, store failpoints + process/wire harness) + +1. **Lost response.** Op committed, receipt durable → verbatim retry returns the + persisted result (`replayed: true`); revision and bytes unchanged. Run through the + tool handler/wire with the first response deliberately discarded, in both protocol + eras, so replay decoration and schema conformance are exercised. +2. **Crash before commit.** A child-process failpoint terminates immediately before the + canonical rename. No receipt and no state change survive → retry applies exactly once. + A thrown in-process exception is not accepted as proof of a process-death boundary. +3. **Reconnect replay.** Server process replaced, client reopens the workspace and holds + a DIFFERENT handle → same `operationId` + semantic args + `expectedStateRev` + + `expectedStateGen` returns the receipt. Proves the binding excludes the transport + (rev 1's failure, pinned red). This case runs through the real process/wire harness; + it is not described as a store-level simulation. +4. **Binding conflict.** Same retained `operationId`, any semantic argument, revision, or + generation different → `OperationIdConflict`, zero bytes moved, ring unchanged. +5. **Eviction, reset, and recreation.** Receipt evicted by ≥cap later commits → verbatim + retry refuses stale. Separately, reset wipes the ring and continues the revision line; + out-of-band recreation is driven back to the old numeric revision with a new + generation. The old envelope refuses in both lineage cases and never re-applies; a + fresh `operationId` naming the current revision and generation proceeds. + +## Verification (acceptance, every box) + +1. **Replay proofs:** the five tests above, each seen red against a deliberately broken + variant at least once (convention 2; red against the specific commit under test). + No-op coverage separately proves lost response + unchanged state repeats the no-op, + while lost response + intervening revision refuses stale and stores no binding. +2. **CLI equivalence:** each tool's committed record deep-equals the CLI verb's record + for the same fixture (ids compared structurally where deterministic-vs-random by + design). +3. **CAS:** stale rev, stale generation (including same-revision recreation), and + no-record-yet each refuse with zero byte movement (byte-snapshot proof, extending the + 3b harness over every refusal path). +4. **One rev per op:** any committed write moves the revision exactly once, receipt + included; a no-op moves nothing and records nothing. +5. **Authority:** every write tool is absent from a read-only server's `tools/list` and + unreachable through its dispatcher; on a `--write` server, foreign/stale/revoked/ + closed handles refuse with the one message; propose-only + `--write` refuses startup. +6. **Schema conformance:** every success result — live and replayed — and every structured + tool error validates against its declared `outputSchema`. Tests keep every designed + success under the entry cap; a runtime overflow refuses before commit and moves zero + bytes. +7. **Aperture:** fog recorded once under race (two writers, one record), `recordedFog` + truthful on both outcomes, byte-pure when `mapRequired` is false, stale refusal + proven when the revision moved between read and score. +8. **Error funnel:** a table-driven fault suite covers boundary validation, missing state, + generation/revision CAS, receipt corruption, operation and deterministic-id conflict, + result-cap overflow, journal failures, and every domain refusal. Each crosses + `safeWriteError`; wire text matches the sentence allowlist; no path, errno, or store + location appears. +9. **Real wire:** both eras over `bin/ratchet-mcp --write`: open → write → re-read shows + the committed record; kill the server, reconnect, retry shows the receipt. +10. **Regression:** `npm test`, `node bin/ratchet doctor`, `npm run preflight` green; + `tools/list` whole-object assertions pin both rosters (read-only and `--write`) in + advertised order. + +## Decision points for ratification (owner: Danny) + +- **D1 — Safe-core roster.** The 10-tool table = step 4; the defect family and + `ledger.update` deferred to 4b behind a WAL design; `defect.waive` and waiver + arguments, including record-scope artifact closure, excluded permanently (wire + self-authorization). Recommended: as specified. +- **D2 — Receipts inside the state record.** Public-shape addition (`state.operations`, + cap 32, ≤4 KiB per entry, persisted result envelopes) plus revision-and-generation + CAS. The alternative — a sibling receipts file — reopens the two-file atomicity hole. + Recommended: as specified. +- **D3 — `--write` opt-in.** A server without the flag registers no write tools; with + it, propose-only agents are refused at startup. Alternative (always-writable server, + per-call guard) advertises capability the operator never granted. Recommended: as + specified. +- **D4 — 4b commitment.** Deferring the cross-file family is only honest if 4b is a + named successor with the WAL route on record, not a quiet drop. This spec is that + record; 4b gets its own design doc before any cross-file verb ships. + +--- + +Design rev 1 traced by: claude-fable-5 +Review: independent five-voice attack + graph analysis (do-not-ship, 2026-07-31); +rev 2 rulings and safe-core rewrite traced by: claude-fable-5 +Rev 3 verification and contract tightening traced by: openai-codex-gpt-5 diff --git a/package.json b/package.json index 0b9b5cb..f9909b1 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "node": ">=18" }, "scripts": { - "test": "node test/cli.test.js && node test/evolve.test.js && node test/plugin-shape.test.js && node test/concurrency.test.js && node test/mcp-rpc.test.js && node test/mcp-workspace.test.js && node test/mcp-handles.test.js && node test/mcp-repository.test.js && node test/mcp-server.test.js && node test/mcp-prompts.test.js && node test/mcp-toctou.test.js && node test/mcp-entry.test.js", + "test": "node test/cli.test.js && node test/evolve.test.js && node test/plugin-shape.test.js && node test/concurrency.test.js && node test/mcp-rpc.test.js && node test/mcp-workspace.test.js && node test/mcp-handles.test.js && node test/mcp-repository.test.js && node test/mcp-server.test.js && node test/mcp-write.test.js && node test/mcp-prompts.test.js && node test/mcp-toctou.test.js && node test/mcp-entry.test.js", "test:concurrency": "node test/concurrency.test.js", "prompts-gen": "node scripts/prompts-gen.js", "preflight": "node scripts/preflight.js", diff --git a/src/artifacts.js b/src/artifacts.js index 4e4c0ee..1009716 100644 --- a/src/artifacts.js +++ b/src/artifacts.js @@ -4,6 +4,16 @@ const state = require('./state'); const schemas = require('./schemas'); const scoring = require('./scoring'); const lifecycle = require('./lifecycle'); +const journal = require('./evolve/journal'); + +// A domain refusal both boundaries must tell apart from damage carries a code: +// the CLI prints the message, the MCP funnel maps the code to its one +// allowlisted sentence and the raw text never crosses the wire. +function coded(code, message) { + const e = new Error(message); + e.code = code; + return e; +} // Thin helpers for the two collections skills touch most: artifacts + defects. // Every write flips state.dirty so the Stop hook can nag if nothing was @@ -118,9 +128,10 @@ function closeFogLoops(s, record, now) { } } -function reviseArtifact(cwd, s, existing, item, now) { +function reviseArtifact(s, existing, item, now, mintId) { if (lifecycle.isClosed(existing)) { - throw new Error( + throw coded( + 'ERATCHETARTIFACTCLOSED', `artifact "${existing.id}" is closed — closure is a historical fact and cannot be edited away. ` + 'Record a new artifact with "revises": "", or retract this one (ratchet retract --reason "").' ); @@ -143,7 +154,7 @@ function reviseArtifact(cwd, s, existing, item, now) { const changed = hasLegacyHolesShape(existing) || ARTIFACT_MUTABLE.some((k) => JSON.stringify(next[k]) !== JSON.stringify(canonicalExisting[k])); - if (!changed) return existing; + if (!changed) return { record: existing, action: 'unchanged' }; // '' is the canonical "no lineage" and must not persist as an empty string. if (!next.revises) delete next.revises; @@ -151,33 +162,40 @@ function reviseArtifact(cwd, s, existing, item, now) { next.updatedAt = now; s.artifacts[s.artifacts.indexOf(existing)] = next; s.dirty = true; - s.history.push({ id: state.makeId('hist'), at: now, event: 'artifact.revised', note: `${next.id} → rev ${next.rev}` }); + s.history.push({ id: mintId('hist', 'history'), at: now, event: 'artifact.revised', note: `${next.id} → rev ${next.rev}` }); if (next.kind === 'unknown-map') closeFogLoops(s, next, now); - state.saveState(cwd, s); - return next; + return { record: next, action: 'revised' }; } -function addArtifact(cwd, item) { +// The transaction-shaped core, shared with the MCP write tool: mutates the open +// transaction's state, never loads or saves, mints ids through the caller. +function applyAdd(s, item, mintId) { assertArtifactInput(item); - const s = state.loadState(cwd); const now = schemas.nowIso(); const id = item.id ? String(item.id) : ''; if (id) { const existing = findUniqueArtifact(s, id, 'revise'); - if (existing) return reviseArtifact(cwd, s, existing, item, now); + if (existing) return reviseArtifact(s, existing, item, now, mintId); } const kind = item.kind ? String(item.kind) : 'artifact'; // Same normalization as a revision — `revises` included, which is provenance // only: naming what this supersedes does not retire it. A lineage claim is not // a lifecycle event; only `retract` and `close` are. - const record = { id: id || state.makeId('art'), at: now, rev: 1, kind, ...canonicalFields(kind, item, null) }; + const record = { id: id || mintId('art', 'record'), at: now, rev: 1, kind, ...canonicalFields(kind, item, null) }; if (!record.revises) delete record.revises; s.artifacts.push(record); s.dirty = true; - s.history.push({ id: state.makeId('hist'), at: record.at, event: 'artifact.add', note: record.title }); + s.history.push({ id: mintId('hist', 'history'), at: record.at, event: 'artifact.add', note: record.title }); if (record.kind === 'unknown-map') closeFogLoops(s, record, now); - state.saveState(cwd, s); - return record; + return { record, action: 'created' }; +} + +function addArtifact(cwd, item) { + const s = state.loadState(cwd); + const res = applyAdd(s, item, (prefix) => state.makeId(prefix)); + // An unchanged revision writes nothing — the no-rev-churn rule above. + if (res.action !== 'unchanged') state.saveState(cwd, s); + return res.record; } // Which live artifact does this defect attack? Guessing wrong is worse than @@ -374,35 +392,37 @@ function transitionDefect(cwd, id, toStatus, meta = {}) { // flips to `retracted` so it stops steering cold sessions and its holes stop // draining confidence. This is the move the T2.3 re-scope doc needed when its // central premise ("no endpoint exists") was disproven by the live seam. -function retractArtifact(cwd, id, { reason = '', supersededBy = '' } = {}) { - const s = state.loadState(cwd); +function applyRetract(s, id, { reason = '', supersededBy = '' } = {}, mintId) { const a = findUniqueArtifact(s, id, 'retract'); - if (!a) throw new Error(`no artifact with id "${id}"`); + if (!a) throw coded('ERATCHETUNKNOWNID', `no artifact with id "${id}"`); // A probe retraction is its lifecycle exit and must state which one: the // code died (disposed) or was explicitly rebuilt for keep (promoted). A // vague reason would let residue stop draining without either outcome. if (a.kind === 'probe') { if (!/^(disposed|promoted):/i.test(reason)) { - throw new Error( + throw coded( + 'ERATCHETRETRACT', 'a probe retraction must state its outcome: --reason must start with "disposed:" (code reverted, finding recorded) or "promoted:" (rebuilt for keep)' ); } if (/^promoted:/i.test(reason)) { if (!supersededBy) { - throw new Error('a promoted probe requires --superseded-by — the build-for-keep that replaced it'); + throw coded('ERATCHETRETRACT', 'a promoted probe requires --superseded-by — the build-for-keep that replaced it'); } // "Promoted" is a claim that real work replaced the probe. An id nobody // recorded, or another probe, is the residue-keeps-shipping path wearing // a promotion label. const replacement = findUniqueArtifact(s, supersededBy, 'promote a probe into'); if (!replacement) { - throw new Error( + throw coded( + 'ERATCHETRETRACT', `--superseded-by "${supersededBy}" names no artifact in this state — a promotion must point at the ` + 'recorded build-for-keep that replaced the probe' ); } if (String(replacement.kind) === 'probe') { - throw new Error( + throw coded( + 'ERATCHETRETRACT', `--superseded-by "${supersededBy}" is itself a probe — a promotion must point at a build-for-keep, not another probe` ); } @@ -413,13 +433,114 @@ function retractArtifact(cwd, id, { reason = '', supersededBy = '' } = {}) { a.retracted = { at: now, reason, supersededBy, keptForProvenance: true }; s.dirty = true; s.history.push({ - id: state.makeId('hist'), + id: mintId('hist', 'history'), at: now, event: 'artifact.retracted', note: `${id}: ${reason}${supersededBy ? ` → superseded by ${supersededBy}` : ''}`, }); + return a; +} + +function retractArtifact(cwd, id, opts) { + const s = state.loadState(cwd); + const a = applyRetract(s, id, opts, (prefix) => state.makeId(prefix)); state.saveState(cwd, s); return a; } -module.exports = { addArtifact, addDefect, transitionDefect, retractArtifact }; +// The closure gate's mutation, moved here from the CLI router so the MCP tool +// runs the SAME gate. The caller owns the transaction AND the journal lock +// (lock order: workspace → file) — the blocker check and the certificate it +// authorizes must share one window, or a REVERT landing between them closes an +// artifact on revoked proof. Over MCP there are no waiver arguments: opts stays +// empty, so record-scope proof and open holes refuse here by design. +function applyClose(cwd, s, id, opts, mintId) { + const owner = String(opts.owner || '').trim(); + const reason = String(opts.reason || '').trim(); + const waiveHoles = opts.waiveHoles === true; + + const matches = (s.artifacts || []).filter((a) => a && a.id === id); + if (matches.length > 1) { + throw new Error( + `${matches.length} artifacts share the id "${id}" — refusing to close an ambiguous record. ` + + 'Repair the store first: give the duplicates distinct ids in state.json, then re-run.' + ); + } + const artifact = matches[0]; + if (!artifact) throw coded('ERATCHETUNKNOWNID', `no artifact with id "${id}"`); + + // Idempotent: a second close of an already-certified artifact is a no-op, not + // an error. Re-running a serialize block must never be punished. + if (lifecycle.isClosed(artifact)) return { artifact, fp: null, bound: null, already: true }; + + // FAIL CLOSED on a damaged proof record. A malformed line is a dropped event, + // and a dropped REVERT reads exactly like no REVERT — so a mangled log used to + // make this gate CERTIFY where it should refuse. Absence of evidence is not + // evidence of absence when the record itself is known to be short. + let read = { events: [], malformed: 0, file: journal.logPath(cwd) }; + try { + read = journal.readEventsWithHealth(cwd); + } catch (_e) { + /* unreadable log → no events → the blockers below refuse for want of proof */ + } + if (read.malformed) { + throw coded( + 'ERATCHETCLOSUREBLOCKED', + `cannot close artifact "${id}": proof record damaged — ${read.malformed} unreadable line(s) in ${read.file}. ` + + 'A dropped event is indistinguishable from no event, so this closure cannot be certified. ' + + 'Repair or archive the log, then re-run.' + ); + } + const events = read.events; + const blockers = lifecycle.closureBlockers(s, events, artifact, cwd, { waiveHoles, owner, reason }); + if (blockers.length) { + throw coded( + 'ERATCHETCLOSUREBLOCKED', + `cannot close artifact "${id}" — ${blockers.length} blocker(s):\n` + blockers.map((b) => ` - [${b.code}] ${b.message}`).join('\n') + ); + } + + const fp = lifecycle.fingerprint(cwd, artifact); + // A record-scope close certifies a claim about a RECORD, not about bytes on + // disk — a much weaker proof. It is allowed, but only with a named owner who + // says so out loud, exactly like a seam waiver. + if (fp.hashScope === 'record') { + if (!owner || !reason) { + throw coded( + 'ERATCHETHUMANAUTHORITY', + `cannot close artifact "${id}": its proof is record-scope, not file-scope` + + (fp.downgradeReason ? ` (${fp.downgradeReason})` : ' (the artifact points at no file)') + + ' — that certifies a claim about the record, not about shipped bytes. ' + + 'Authorize it by name: --owner "" --reason "".' + ); + } + } + + const bound = lifecycle.bindingEvent(artifact, events, fp); + const now = schemas.nowIso(); + artifact.status = 'closed'; + artifact.closedAt = now; + artifact.closedBy = bound.id || ''; + artifact.closedRev = fp.rev; + artifact.closedHash = fp.hash; + if (waiveHoles && (artifact.holes || []).length) artifact.holesWaiver = { by: owner, reason }; + s.dirty = true; + s.history.push({ + id: mintId('hist', 'history'), + at: now, + event: 'artifact.closed', + note: `${id} closed at rev ${fp.rev} on ${bound.id || 'bound proof'}${owner ? ` (owner: ${owner})` : ''}`, + }); + return { artifact, fp, bound, already: false }; +} + +module.exports = { + addArtifact, + addDefect, + transitionDefect, + retractArtifact, + assertArtifactInput, + applyAdd, + applyRetract, + applyClose, +}; diff --git a/src/cli.js b/src/cli.js index db303c8..d7c354f 100644 --- a/src/cli.js +++ b/src/cli.js @@ -15,6 +15,7 @@ const md = require('./markdown'); const receipt = require('./receipt'); const journal = require('./evolve/journal'); const schemas = require('./schemas'); +const verbs = require('./verbs'); const VERSION = require('../package.json').version; const PLUGIN_ROOT = path.resolve(__dirname, '..'); @@ -48,11 +49,9 @@ function readStdinSafe() { } } -function coerceScalar(key, value) { - if (key === 'dirty') return value === 'true' || value === true; - if (key === 'confidence') return value === '' ? null : Number(value); - return value; -} +// state.set's meaning moved to src/verbs.js (shared with the MCP write tool); +// the alias keeps every other inline caller reading as before. +const { coerceScalar } = require('./verbs'); // Minimal `--key value` parser for subcommands that carry real values (defect // lifecycle). The top-level router treats every `--flag` as boolean, which is @@ -281,9 +280,7 @@ function cmdState(cwd, sub, rest, asJson, flags = new Set(), argv = []) { } const value = valueParts.join(' '); mutate(cwd, 'state set', (s) => { - s[key] = coerceScalar(key, value); - if (key !== 'dirty') s.dirty = true; - s.history.push({ id: state.makeId('hist'), at: schemas.nowIso(), event: 'state.set', note: `${key} = ${value}` }); + verbs.setScalar(s, key, value, (prefix) => state.makeId(prefix)); }); return out(`${key} set`); } @@ -304,31 +301,11 @@ function cmdState(cwd, sub, rest, asJson, flags = new Set(), argv = []) { ); } const item = readPayload(payloadArg); - // Birth status is forced, not accepted: an assumption born "tested" and a - // loop born "closed" are exactly the two lies that make the drain lie. - const BIRTH_STATUS = { assumptions: 'untested', openLoops: 'open' }; - const birth = BIRTH_STATUS[collection]; - const appended = mutate(cwd, 'state append', (s) => { - if (birth) { - const claimed = item.status == null ? '' : String(item.status); - if (claimed && claimed !== birth) { - throw new Error( - `${collection} are born "${birth}", never "${claimed}" — reaching any other status is a transition ` + - `(ratchet state close ${collection} …), not a birth field` - ); - } - item.status = birth; - const key = String(item.text || '').trim().toLowerCase(); - const dup = key && (s[collection] || []).find((x) => String((x && x.text) || '').trim().toLowerCase() === key); - // The dedup runs UNDER the lock against the reloaded record, so two - // processes appending the same text produce one entry, not two. - if (dup) return { dup }; - } - const record = { id: item.id || state.makeId(schemas.STATE_COLLECTIONS[collection]), at: schemas.nowIso(), ...item }; - s[collection].push(record); - s.dirty = true; - return { record }; - }); + // Birth forcing, dedup-under-lock, and the record shape live in the + // shared verb (src/verbs.js) — one meaning on both boundaries. + const appended = mutate(cwd, 'state append', (s) => + verbs.appendItem(s, collection, item, (prefix) => state.makeId(prefix)) + ); if (appended.dup) return out(`already recorded in ${collection}: ${appended.dup.id}`); return out(`appended to ${collection}: ${appended.record.id}`); } @@ -385,61 +362,20 @@ function cmdStateClose(cwd, argv) { } need(id, `usage: ratchet state close ${collection} ...`); - const to = mutate(cwd, `state close ${collection}`, (s) => closeRecord(s, collection, id, opts)); + // The transition rules live in the shared verb (src/verbs.js); this boundary + // owns the flag spelling and the load/save. + const to = mutate(cwd, `state close ${collection}`, (s) => + verbs.closeRecord(s, collection, id, { + park: opts.park === true, + owner: strOpt(opts.owner), + revisitTrigger: strOpt(opts['revisit-trigger']), + evidence: strOpt(opts.evidence), + outcome: strOpt(opts.outcome), + }, (prefix) => state.makeId(prefix)) + ); return out(`${collection} ${id} → ${to}`); } -// The transition itself, applied to the transaction's state. Split out only so -// the boundary above owns the load/save and this owns the rules. -function closeRecord(s, collection, id, opts) { - const need = (val, msg) => { - if (!val) throw new Error(msg); - return val; - }; - const record = (s[collection] || []).find((x) => x && x.id === id); - if (!record) throw new Error(`no ${collection} entry with id "${id}"`); - const now = schemas.nowIso(); - const evidence = strOpt(opts.evidence); - let to; - - if (collection === 'openLoops') { - if (opts.park === true) { - const owner = need(strOpt(opts.owner), 'parking a loop requires --owner ""'); - const trigger = need( - strOpt(opts['revisit-trigger']), - 'parking a loop requires --revisit-trigger "" — a park with no trigger is a drop' - ); - to = 'parked'; - record.owner = owner; - record.revisitTrigger = trigger; - } else { - need(evidence, 'closing a loop requires --evidence "" — no proof, no close'); - to = 'closed'; - record.evidence = evidence; - } - } else { - const outcome = strOpt(opts.outcome); - if (outcome !== 'tested' && outcome !== 'killed') { - throw new Error('closing an assumption requires --outcome tested|killed — an assumption ends proven or dead'); - } - need(evidence, 'closing an assumption requires --evidence ""'); - to = outcome; - record.evidence = evidence; - } - - const from = record.status || ''; - record.status = to; - record.closedAt = now; - s.dirty = true; - s.history.push({ - id: state.makeId('hist'), - at: now, - event: `${collection === 'openLoops' ? 'loop' : 'assumption'}.${to}`, - note: `${id}: ${from} → ${to}${evidence ? ` — ${evidence}` : ''}`, - }); - return to; -} - // The closure gate. `compile done` says the record is current; this says the // work is finished — and it will only say so when a KEEP is bound to THIS exact // revision. Checkpoint is not closure. @@ -455,92 +391,23 @@ function cmdArtifactClose(cwd, argv) { // appended after the read but before the commit still landed on evidence this // gate had stopped watching, and the artifact still closed on a revoked KEEP. // Lock order: workspace → journal. + // The gate's meaning lives in src/artifacts.js (applyClose), shared with the + // MCP write tool; this boundary owns the flag spelling and the rendering. return out( - state.withWorkspaceMutation(cwd, { action: 'artifact close', alsoLockFile: journal.logPath(cwd) }, (s) => - closeArtifact(cwd, s, id, opts) - ).result + state.withWorkspaceMutation(cwd, { action: 'artifact close', alsoLockFile: journal.logPath(cwd) }, (s) => { + const res = artifacts.applyClose(cwd, s, id, { + owner: strOpt(opts.owner), + reason: strOpt(opts.reason), + waiveHoles: opts['waive-holes'] === true, + }, (prefix) => state.makeId(prefix)); + if (res.already) { + return `artifact ${id} is already closed (rev ${res.artifact.closedRev}, by ${res.artifact.closedBy}) — no change`; + } + return `artifact ${id} closed — rev ${res.fp.rev}, ${res.fp.hashScope} hash ${String(res.fp.hash).slice(0, 8)}, proof ${res.bound.id || '—'}`; + }).result ); } -function closeArtifact(cwd, s, id, opts) { - const matches = (s.artifacts || []).filter((a) => a && a.id === id); - if (matches.length > 1) { - throw new Error( - `${matches.length} artifacts share the id "${id}" — refusing to close an ambiguous record. ` + - 'Repair the store first: give the duplicates distinct ids in state.json, then re-run.' - ); - } - const artifact = matches[0]; - if (!artifact) throw new Error(`no artifact with id "${id}"`); - - // Idempotent: a second close of an already-certified artifact is a no-op, not - // an error. Re-running a serialize block must never be punished. - if (lifecycle.isClosed(artifact)) { - return `artifact ${id} is already closed (rev ${artifact.closedRev}, by ${artifact.closedBy}) — no change`; - } - - const owner = strOpt(opts.owner); - const reason = strOpt(opts.reason); - const waiveHoles = opts['waive-holes'] === true; - - // FAIL CLOSED on a damaged proof record. A malformed line is a dropped event, - // and a dropped REVERT reads exactly like no REVERT — so a mangled log used to - // make this gate CERTIFY where it should refuse. Absence of evidence is not - // evidence of absence when the record itself is known to be short. - let read = { events: [], malformed: 0, file: journal.logPath(cwd) }; - try { - read = journal.readEventsWithHealth(cwd); - } catch (_e) { - /* unreadable log → no events → the blockers below refuse for want of proof */ - } - if (read.malformed) { - throw new Error( - `cannot close artifact "${id}": proof record damaged — ${read.malformed} unreadable line(s) in ${read.file}. ` + - 'A dropped event is indistinguishable from no event, so this closure cannot be certified. ' + - 'Repair or archive the log, then re-run.' - ); - } - const events = read.events; - const blockers = lifecycle.closureBlockers(s, events, artifact, cwd, { waiveHoles, owner, reason }); - if (blockers.length) { - throw new Error( - `cannot close artifact "${id}" — ${blockers.length} blocker(s):\n` + blockers.map((b) => ` - [${b.code}] ${b.message}`).join('\n') - ); - } - - const fp = lifecycle.fingerprint(cwd, artifact); - // A record-scope close certifies a claim about a RECORD, not about bytes on - // disk — a much weaker proof. It is allowed, but only with a named owner who - // says so out loud, exactly like a seam waiver. - if (fp.hashScope === 'record') { - if (!owner || !reason) { - throw new Error( - `cannot close artifact "${id}": its proof is record-scope, not file-scope` + - (fp.downgradeReason ? ` (${fp.downgradeReason})` : ' (the artifact points at no file)') + - ' — that certifies a claim about the record, not about shipped bytes. ' + - 'Authorize it by name: --owner "" --reason "".' - ); - } - } - - const bound = lifecycle.bindingEvent(artifact, events, fp); - const now = schemas.nowIso(); - artifact.status = 'closed'; - artifact.closedAt = now; - artifact.closedBy = bound.id || ''; - artifact.closedRev = fp.rev; - artifact.closedHash = fp.hash; - if (waiveHoles && (artifact.holes || []).length) artifact.holesWaiver = { by: owner, reason }; - s.dirty = true; - s.history.push({ - id: state.makeId('hist'), - at: now, - event: 'artifact.closed', - note: `${id} closed at rev ${fp.rev} on ${bound.id || 'bound proof'}${owner ? ` (owner: ${owner})` : ''}`, - }); - return `artifact ${id} closed — rev ${fp.rev}, ${fp.hashScope} hash ${String(fp.hash).slice(0, 8)}, proof ${bound.id || '—'}`; -} - function cmdLedger(cwd, sub, rest, asJson) { switch (sub) { case 'create': { @@ -671,40 +538,18 @@ function cmdRetract(cwd, argv) { // Serialize the fog the moment the dial names it (a write). Steering the state // never saw cannot drain confidence, warn a cold start, or survive a handoff. -// A propose-only agent still gets the read — no footprint; an already-open fog -// loop or a live unknown-map means the fog is already on the record. The loop -// closes itself when the unknown-map artifact lands (artifacts.js). Returns -// true only when a fog loop was actually written. -function fogAlreadyOnRecord(s) { - const openFog = (s.openLoops || []).some( - (l) => l.status !== 'closed' && String(l.text || '').startsWith(schemas.FOG_LOOP_PREFIX) - ); - const liveMap = (s.artifacts || []).some( - (a) => a.kind === 'unknown-map' && a.status !== 'retracted' && a.status !== 'superseded' - ); - return openFog || liveMap; -} - +// A propose-only agent still gets the read — no footprint. The write's meaning +// lives in src/verbs.js (recordFog), shared with the MCP score.aperture tool. function recordApertureFog(cwd, result) { if (!result.mapRequired || proposeOnlyAgent()) return false; // Double-checked on purpose. The common case is "already on the record", and // taking the workspace lock for a read that will write nothing would make an // aperture score queue behind every unrelated writer. The check that DECIDES - // runs under the lock, against the state reloaded there. - if (fogAlreadyOnRecord(state.loadState(cwd))) return false; + // runs under the lock, against the state reloaded there (inside recordFog). + if (verbs.fogAlreadyOnRecord(state.loadState(cwd))) return false; let recorded = false; state.withWorkspaceMutation(cwd, { action: 'fog.recorded' }, (s) => { - if (fogAlreadyOnRecord(s)) return; // the racer that got here first recorded it - const now = schemas.nowIso(); - s.openLoops.push({ - id: state.makeId('loop'), - at: now, - text: `${schemas.FOG_LOOP_PREFIX} (aperture ${result.level}, score ${result.score}/10) — run /ratchet:map; closes when the unknown-map artifact lands`, - status: 'open', - }); - s.dirty = true; - s.history.push({ id: state.makeId('hist'), at: now, event: 'fog.recorded', note: `aperture ${result.level} raised mapRequired` }); - recorded = true; + recorded = verbs.recordFog(s, result, (prefix) => state.makeId(prefix)); }); return recorded; } @@ -847,11 +692,8 @@ function cmdHook(cwd, sub) { // state, and it must never be mistaken for closure: it says the record is // current, not that the work is finished. function cmdCompileDone(cwd, asJson) { - const now = schemas.nowIso(); const s = mutate(cwd, 'compile done', (st) => { - st.lastCompileAt = now; - st.dirty = false; - st.history.push({ id: state.makeId('hist'), at: now, event: 'compile.done', note: 'state serialized' }); + verbs.compileDone(st, (prefix) => state.makeId(prefix)); return st; }); diff --git a/src/mcp/main.js b/src/mcp/main.js index f2455e5..fe70bef 100644 --- a/src/mcp/main.js +++ b/src/mcp/main.js @@ -22,6 +22,7 @@ const path = require('path'); const pkg = require('../../package.json'); const mcp = require('./server'); const stdio = require('./stdio'); +const state = require('../state'); const VERSION = pkg.version; const ROOTS_ENV = 'RATCHET_MCP_ROOTS'; @@ -43,6 +44,8 @@ const USAGE = [ ' Must be an existing, fully qualified directory.', ` --project-root Allow the directory named by ${PROJECT_ENV}. For an MCP`, ' host that states the project it opened; refuses if unset.', + ' --write Register the write tools. Without it the server is', + ' read-only and advertises no write capability.', ' --help, -h Show this message.', ' --version Print the version.', '', @@ -60,7 +63,7 @@ const USAGE = [ // direction to be wrong in. function parseArgs(argv) { const roots = []; - const flags = { help: false, version: false, projectRoot: false }; + const flags = { help: false, version: false, projectRoot: false, write: false }; const errors = []; for (let i = 0; i < argv.length; i++) { @@ -77,6 +80,10 @@ function parseArgs(argv) { flags.projectRoot = true; continue; } + if (arg === '--write') { + flags.write = true; + continue; + } let value = null; if (arg === '--root') { value = argv[i + 1]; @@ -165,9 +172,22 @@ function start(argv, io) { return { exitCode: 2 }; } + // A propose-only agent may run a read-only server — orientation is its job. + // Granting it writes is a misconfiguration, refused HERE so the operator + // sees it at launch instead of every write failing with a per-call mystery. + // assertMayWrite at the mutation boundary remains the backstop underneath. + if (parsed.flags.write) { + const role = state.proposeOnlyAgent(io.env); + if (role) { + err.write(`ratchet-mcp: --write conflicts with RATCHET_AGENT=${role}, a propose-only role\n`); + err.write('ratchet-mcp: unset RATCHET_AGENT (or set it to scribe), or launch without --write\n'); + return { exitCode: 2 }; + } + } + let server; try { - server = mcp.createServer({ roots }); + server = mcp.createServer({ roots, write: parsed.flags.write }); } catch (e) { // Root validation is the one configuration check that must fail loudly: // createRoots refuses a root that does not exist, is not a directory, or is @@ -177,7 +197,7 @@ function start(argv, io) { } const attached = stdio.attach(server, { input: io.stdin, output: out }); - err.write(`ratchet-mcp ${VERSION} — ${roots.length} root(s) from ${source}\n`); + err.write(`ratchet-mcp ${VERSION} — ${roots.length} root(s) from ${source}${parsed.flags.write ? ', writes enabled' : ''}\n`); return { exitCode: null, attached, server }; } diff --git a/src/mcp/ops.js b/src/mcp/ops.js new file mode 100644 index 0000000..76ec9f2 --- /dev/null +++ b/src/mcp/ops.js @@ -0,0 +1,240 @@ +'use strict'; + +// Torque MCP step 4: the write-operation mechanism. +// +// Everything here serves one contract: a retried write can never apply twice, +// never claim an outcome it does not have, and survives a server restart. +// `expectedStateRev`/`expectedStateGen` give safety (a stale decision is +// refused, not merged); the operation receipt gives committed-write +// observability (a retry learns WHOSE write landed). The receipt lives inside +// the state record so it is durable atomically with the commit it describes — +// two files cannot rename atomically, so a sibling receipt file could certify +// a write that never landed or lose one that did. +// +// Traced by: claude-fable-5 + +const crypto = require('crypto'); +const fs = require('fs'); + +const schemas = require('../schemas'); + +// Receipts kept per store. Evicting is safe by construction: an entry only +// leaves after OPERATIONS_CAP later commits, so the evicted operation's +// expectedStateRev is deeply stale and its verbatim retry refuses rather than +// re-applying. +const OPERATIONS_CAP = 32; +// A receipt entry is identifiers and verdicts, never document bodies. The cap +// is a fail-closed tripwire for a future tool that violates that: the write +// refuses BEFORE commit instead of truncating a result a replay would later +// serve as truth. +const RECEIPT_ENTRY_CAP = 4096; + +// Object keys sort by Unicode CODE POINT, not UTF-16 code unit: the default +// string comparison orders astral-plane keys after U+E000..U+FFFF, and a hash +// whose input order depends on the encoding quirk is not canonical. +function compareCodePoints(a, b) { + const A = Array.from(a); + const B = Array.from(b); + const n = Math.min(A.length, B.length); + for (let i = 0; i < n; i++) { + const d = A[i].codePointAt(0) - B[i].codePointAt(0); + if (d) return d; + } + return A.length - B.length; +} + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + const out = {}; + for (const key of Object.keys(value).sort(compareCodePoints)) out[key] = canonicalize(value[key]); + return out; + } + return value; +} + +// Canonical encoding: recursively key-sorted, array order preserved, no +// Unicode normalization, then JSON.stringify. Inputs have already crossed +// JSON-RPC, so JavaScript-only values (undefined, functions, cycles) cannot +// reach this. +function canonicalStringify(value) { + return JSON.stringify(canonicalize(value)); +} + +// The binding names the operation's MEANING, not its transport: tool, semantic +// arguments, and the revision + generation the client decided against. +// `workspaceHandle` is connection-scoped and `operationId` is the key itself — +// hashing either would make a legitimate reconnect retry look like a different +// operation. +function bindingHash(tool, semanticArgs, expectedStateRev, expectedStateGen) { + const encoded = canonicalStringify({ + tool, + args: semanticArgs, + expectedStateRev, + expectedStateGen, + }); + return `sha256:${crypto.createHash('sha256').update(encoded, 'utf8').digest('hex')}`; +} + +// Record ids minted by a write derive from the operation's meaning, so a +// crash-boundary re-application converges on the SAME record instead of +// minting a duplicate. 32 hex digits keep 128 bits of the digest — collisions +// are then a fault to refuse loudly (see mintId below), never a probability to +// budget for. +function deriveId(prefix, expectedStateGen, tool, argsHash, role) { + const digest = crypto + .createHash('sha256') + .update(`${expectedStateGen}\n${tool}\n${argsHash}\n${role}`, 'utf8') + .digest('hex'); + return `${prefix}-${digest.slice(0, 32)}`; +} + +// Every id-bearing state collection. A derived id colliding with ANY existing +// record refuses before the domain can interpret it — artifact add treats an +// existing id as "revise this artifact", and entropy is not permission to +// merge into a record the operation never named. +const ID_COLLECTIONS = ['decisions', 'artifacts', 'defects', 'assumptions', 'openLoops', 'history', 'operations']; + +function recordIdExists(s, id) { + for (const name of ID_COLLECTIONS) { + const list = s[name]; + if (!Array.isArray(list)) continue; + for (const item of list) { + if (item && typeof item === 'object' && String(item.id) === id) return true; + } + } + return false; +} + +function successResult(committed, stateRev, verbFields) { + return Object.assign({ ok: true, committed, stateRev, replayed: false }, verbFields || {}); +} + +function revOf(s) { + return s && Number.isInteger(s.rev) ? s.rev : 0; +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +// The ordered write semantics, all under the one workspace lock the mutation +// boundary takes. The checks live INSIDE the transaction (not in +// withWorkspaceMutation's own expectedStateRev opt) because the replay lookup +// must come FIRST: a committed operation's verbatim retry is stale by +// definition — its receipt is the answer, and a revision refusal ahead of the +// ring lookup would turn every legitimate replay into a dead end. +// +// Outcome kinds: replayed | conflict | stateMissing | staleGen | staleRev | +// noop | committed | idConflict | capOverflow | unknownId. Every kind except +// `committed` moves zero bytes — a refusal that leaves a fresh store or a +// half-applied verb behind is not a refusal. +// Coded domain throws that are outcomes, not faults. Each rides out of the +// aborted transaction as a zero-byte refusal kind the boundary maps to its one +// allowlisted sentence — the raw message (which may name store files) never +// crosses the wire. +const CODED_OUTCOMES = { + ERATCHETIDCONFLICT: 'idConflict', + ERATCHETRECEIPTCAP: 'capOverflow', + ERATCHETUNKNOWNID: 'unknownId', + ERATCHETARTIFACTCLOSED: 'artifactClosed', + ERATCHETCLOSUREBLOCKED: 'closureBlocked', + ERATCHETHUMANAUTHORITY: 'humanAuthority', + ERATCHETRETRACT: 'retractRefused', +}; + +function executeWrite(opts) { + const { state, root, tool, operationId, expectedStateRev, expectedStateGen, semanticArgs, apply, alsoLockFile } = opts; + const argsHash = bindingHash(tool, semanticArgs, expectedStateRev, expectedStateGen); + + // Refusing over a store that does not exist must not CREATE it: the mutation + // boundary's own load would initialize a missing record before the refusal + // could be spoken. After workspace.open this store exists, so this only + // fires for out-of-band destruction — the server-local damage class. + if (!fs.existsSync(state.statePath(root))) return { kind: 'stateMissing' }; + + let outcome = null; + try { + // `alsoLockFile` extends the transaction over a second file, commit + // included (the closure gate reads its proof from the journal) — same + // contract as the CLI boundary, lock order workspace → file. + state.withWorkspaceMutation(root, alsoLockFile ? { action: tool, alsoLockFile } : { action: tool }, (s) => { + // Lookup against the ring the lock re-read — pre-lock peeks could race a + // concurrent commit of this same operationId into a duplicate-id ring. + const ring = Array.isArray(s.operations) ? s.operations : null; + const hit = ring ? ring.find((entry) => entry && entry.id === operationId) : undefined; + if (hit) { + outcome = hit.argsHash === argsHash + ? { kind: 'replayed', result: clone(hit.result) } + : { kind: 'conflict' }; + return; + } + if (String(s.gen || '') !== expectedStateGen) { + outcome = { kind: 'staleGen', actualStateGen: String(s.gen || '') || null }; + return; + } + const rev = revOf(s); + if (rev !== expectedStateRev) { + outcome = { kind: 'staleRev', actualStateRev: rev }; + return; + } + const before = JSON.stringify(s); + const mintId = (prefix, role) => { + const id = deriveId(prefix, expectedStateGen, tool, argsHash, role); + if (recordIdExists(s, id)) { + const e = new Error(`derived id ${id} already names a record`); + e.code = 'ERATCHETIDCONFLICT'; + throw e; + } + return id; + }; + const verbFields = apply(s, mintId) || {}; + if (JSON.stringify(s) === before) { + // No receipt for a no-op: it moved nothing, so its second application + // moves nothing either. The safety claim needs no durability — and the + // observability limit is stated in the spec, not hidden here. + outcome = { kind: 'noop', result: successResult(false, rev, verbFields) }; + return; + } + const result = successResult(true, rev + 1, verbFields); + const entry = { + id: operationId, + tool, + argsHash, + gen: expectedStateGen, + rev: rev + 1, + at: schemas.nowIso(), + result, + }; + if (JSON.stringify(entry).length > RECEIPT_ENTRY_CAP) { + const e = new Error(`operation receipt exceeds ${RECEIPT_ENTRY_CAP} bytes`); + e.code = 'ERATCHETRECEIPTCAP'; + throw e; + } + // The ring is created on the first committed write so a refusal against a + // pre-step-4 record never mutates it just by being looked at. + if (!Array.isArray(s.operations)) s.operations = []; + s.operations.push(entry); + while (s.operations.length > OPERATIONS_CAP) s.operations.shift(); + outcome = { kind: 'committed', result }; + }); + } catch (error) { + // A throw inside the transaction aborts it — nothing was committed. The + // coded throws become outcomes; anything else is the caller's to map + // through its one error funnel. + const kind = error && CODED_OUTCOMES[error.code]; + if (kind) return { kind }; + throw error; + } + return outcome; +} + +module.exports = { + OPERATIONS_CAP, + RECEIPT_ENTRY_CAP, + canonicalStringify, + bindingHash, + deriveId, + recordIdExists, + executeWrite, +}; diff --git a/src/mcp/server.js b/src/mcp/server.js index ae800c7..848ff48 100644 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -31,18 +31,31 @@ // store it initialized (a record destroyed AFTER open meets the loaders' // designed self-repair; that fail-closed-vs-repair choice is parked, see spec). // Step 3b traced by: claude-opus-5 +// +// Step 4 (safe core) adds the first write tools, registered ONLY when the +// server was launched with the write opt-in: an unflagged server does not +// advertise what the operator never granted. Every write names the revision +// AND generation it decided against (refused stale, never merged) and carries +// an operationId whose receipt — durable inside the state record itself — +// makes a crash-boundary retry return the recorded outcome instead of applying +// twice. See docs/superpowers/specs/2026-07-31-mcp-write-tools-design.md. +// Step 4 traced by: claude-fable-5 const handles = require('./handles'); +const ops = require('./ops'); const prompts = require('./prompts'); const repository = require('./repository'); const rpc = require('./rpc'); const workspace = require('./workspace'); +const artifacts = require('../artifacts'); const coldStart = require('../coldStart'); const journal = require('../evolve/journal'); const lifecycle = require('../lifecycle'); const receipt = require('../receipt'); +const schemas = require('../schemas'); const scoring = require('../scoring'); const state = require('../state'); +const verbs = require('../verbs'); const pkg = require('../../package.json'); const LIST_TTL_MS = 300000; @@ -86,6 +99,11 @@ const TOOL = Object.freeze({ repositoryId: { type: 'string' }, worktreeId: { type: 'string' }, stateRev: { type: 'integer' }, + // The store LINEAGE the revision counts within. A write names both: a + // revision number alone can recur when a store is destroyed and + // recreated out-of-band, and a generation the client did not observe is + // a world it never decided against. + stateGen: { type: 'string' }, resources: { type: 'object', properties: { @@ -97,7 +115,7 @@ const TOOL = Object.freeze({ additionalProperties: false, }, }, - required: ['workspaceHandle', 'repositoryId', 'worktreeId', 'stateRev', 'resources'], + required: ['workspaceHandle', 'repositoryId', 'worktreeId', 'stateRev', 'stateGen', 'resources'], additionalProperties: false, }, annotations: { @@ -241,6 +259,541 @@ const FRICTION_TOOL = Object.freeze({ annotations: READ_ONLY, }); +// The shared write envelope: every write names the workspace by handle, the +// revision AND generation it decided against, and its own retry key. The +// operationId's floor of 22 chars fits a UUID's entropy in base64url; syntax +// cannot prove entropy, so the descriptor says MUST-NOT-reuse out loud. +const OPERATION_ID = /^[A-Za-z0-9_-]{22,128}$/; + +const WRITE_ENVELOPE_PROPS = Object.freeze({ + workspaceHandle: { + type: 'string', + description: 'The opaque handle workspace.open minted on this connection.', + }, + expectedStateRev: { + type: 'integer', + minimum: 0, + description: 'The state revision this write was decided against, from workspace.open or the state resource. A mismatch refuses; nothing is merged.', + }, + expectedStateGen: { + type: 'string', + description: 'The store generation the revision was observed in, from workspace.open.stateGen. Pins the lineage a recreated store cannot fake.', + }, + operationId: { + type: 'string', + pattern: OPERATION_ID.source, + description: 'Client-generated retry key (UUIDv4 or >=128 bits of entropy). Retry the SAME operation with the same id; never reuse one for a different operation.', + }, +}); +const WRITE_ENVELOPE_KEYS = Object.freeze(Object.keys(WRITE_ENVELOPE_PROPS)); + +// One error branch for every write refusal that crosses as a tool result. +// isError does not exempt structuredContent from the declared schema, so the +// refusals conform too. +const WRITE_ERROR_BRANCH = Object.freeze({ + type: 'object', + properties: { + ok: { const: false }, + error: { + enum: [ + 'StateNotInitialized', + 'StaleGeneration', + 'StaleStateRev', + 'OperationIdConflict', + 'DeterministicIdConflict', + 'UnknownRecordId', + 'ArtifactClosed', + 'ClosureBlocked', + 'HumanAuthorityRequired', + 'RetractRefused', + 'WriteFailed', + ], + }, + message: { type: 'string' }, + expectedStateRev: { type: ['integer', 'null'] }, + actualStateRev: { type: ['integer', 'null'] }, + expectedStateGen: { type: ['string', 'null'] }, + actualStateGen: { type: ['string', 'null'] }, + }, + required: ['ok', 'error', 'message'], + additionalProperties: false, +}); + +function writeSuccessBranch(verbProps) { + return { + type: 'object', + properties: Object.assign({ + ok: { const: true }, + committed: { type: 'boolean' }, + stateRev: { type: 'integer' }, + replayed: { type: 'boolean' }, + }, verbProps), + required: ['ok', 'committed', 'stateRev', 'replayed', ...Object.keys(verbProps)], + additionalProperties: false, + }; +} + +const STATE_SET_USAGE = + 'state.set requires exactly: workspaceHandle, expectedStateRev, expectedStateGen, operationId, key, value'; + +const STATE_SET_TOOL = Object.freeze({ + name: 'state.set', + title: 'Set one Torque session-state scalar', + description: + 'Set one settable session-state scalar (title, objective, bottleneck, phase, nextAction, nextCommand, confidence) on an opened workspace. CAS-bound: refuses unless expectedStateRev and expectedStateGen match the record, and replays the recorded outcome when the same operationId retries the same operation.', + inputSchema: { + type: 'object', + properties: Object.assign({}, WRITE_ENVELOPE_PROPS, { + key: { type: 'string', enum: [...schemas.STATE_SCALARS] }, + value: { type: 'string', description: 'The value to record; confidence coerces to a number, everything else stays text.' }, + }), + required: [...WRITE_ENVELOPE_KEYS, 'key', 'value'], + additionalProperties: false, + }, + outputSchema: { + oneOf: [ + writeSuccessBranch({ key: { type: 'string', enum: [...schemas.STATE_SCALARS] } }), + WRITE_ERROR_BRANCH, + ], + }, + annotations: { + readOnlyHint: false, + // Setting a scalar overwrites the previous value; provenance in history + // does not make an overwrite additive. + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, +}); + +// A status transition or an overwrite is destructive; only genuinely additive +// writes get destructiveHint:false. Provenance surviving in history does not +// make an overwrite additive. +const WRITE_DESTRUCTIVE = Object.freeze({ + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, +}); + +// The appendable collections: STATE_COLLECTIONS minus the two whose gated +// constructors a raw append would bypass — that is how a "closed" artifact or +// a "resolved" defect gets minted with no transition behind it. +const APPENDABLE = Object.freeze( + Object.keys(schemas.STATE_COLLECTIONS).filter((name) => name !== 'artifacts' && name !== 'defects') +); + +const STATE_APPEND_USAGE = + 'state.append requires exactly: workspaceHandle, expectedStateRev, expectedStateGen, operationId, collection, item (an object)'; + +const STATE_APPEND_TOOL = Object.freeze({ + name: 'state.append', + title: 'Append one record to a Torque session collection', + description: + 'Append one record to a session-state collection (decisions, assumptions, openLoops, touchedFiles, history) on an opened workspace. Assumptions and open loops are born in their birth status — never closed, tested, or killed — and dedup by text against the live record. Artifacts and defects are not appendable: their constructors are gated (artifact.add, the CLI defect verbs). CAS-bound like every write.', + inputSchema: { + type: 'object', + properties: Object.assign({}, WRITE_ENVELOPE_PROPS, { + collection: { type: 'string', enum: [...APPENDABLE] }, + item: { + type: 'object', + description: 'The record to append. A status field on assumptions/openLoops may only claim the birth status; ids are minted deterministically when absent.', + }, + }), + required: [...WRITE_ENVELOPE_KEYS, 'collection', 'item'], + additionalProperties: false, + }, + outputSchema: { + oneOf: [ + writeSuccessBranch({ + collection: { type: 'string', enum: [...APPENDABLE] }, + recordId: { type: 'string' }, + deduped: { type: 'boolean' }, + }), + WRITE_ERROR_BRANCH, + ], + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, +}); + +const OPEN_LOOP_CLOSE_USAGE = + 'open_loop.close requires exactly: workspaceHandle, expectedStateRev, expectedStateGen, operationId, id, evidence (non-empty strings)'; + +const OPEN_LOOP_CLOSE_TOOL = Object.freeze({ + name: 'open_loop.close', + title: 'Close an open loop with evidence', + description: + 'Close one open loop on an opened workspace with the evidence that actually closed it — no proof, no close. CAS-bound like every write.', + inputSchema: { + type: 'object', + properties: Object.assign({}, WRITE_ENVELOPE_PROPS, { + id: { type: 'string', minLength: 1 }, + evidence: { type: 'string', minLength: 1, description: 'What actually closed the loop.' }, + }), + required: [...WRITE_ENVELOPE_KEYS, 'id', 'evidence'], + additionalProperties: false, + }, + outputSchema: { + oneOf: [ + writeSuccessBranch({ openLoopId: { type: 'string' }, status: { const: 'closed' } }), + WRITE_ERROR_BRANCH, + ], + }, + annotations: WRITE_DESTRUCTIVE, +}); + +const OPEN_LOOP_PARK_USAGE = + 'open_loop.park requires exactly: workspaceHandle, expectedStateRev, expectedStateGen, operationId, id, owner, revisitTrigger (non-empty strings)'; + +const OPEN_LOOP_PARK_TOOL = Object.freeze({ + name: 'open_loop.park', + title: 'Park an open loop with an owner and a revisit trigger', + description: + 'Park one open loop on an opened workspace. Parking stops the nagging but the loop still drains confidence: the owner is attribution for an unanswered question, never a waiver or an approval token. CAS-bound like every write.', + inputSchema: { + type: 'object', + properties: Object.assign({}, WRITE_ENVELOPE_PROPS, { + id: { type: 'string', minLength: 1 }, + owner: { type: 'string', minLength: 1, description: 'Who carries the parked loop. Attribution, not authorization.' }, + revisitTrigger: { type: 'string', minLength: 1, description: 'What brings the loop back — a park with no trigger is a drop.' }, + }), + required: [...WRITE_ENVELOPE_KEYS, 'id', 'owner', 'revisitTrigger'], + additionalProperties: false, + }, + outputSchema: { + oneOf: [ + writeSuccessBranch({ openLoopId: { type: 'string' }, status: { const: 'parked' } }), + WRITE_ERROR_BRANCH, + ], + }, + annotations: WRITE_DESTRUCTIVE, +}); + +const ASSUMPTION_CLOSE_USAGE = + 'assumption.close requires exactly: workspaceHandle, expectedStateRev, expectedStateGen, operationId, id, outcome (tested|killed), evidence (non-empty)'; + +const ASSUMPTION_CLOSE_TOOL = Object.freeze({ + name: 'assumption.close', + title: 'Close an assumption as tested or killed', + description: + 'Close one assumption on an opened workspace with the result that settled it — an assumption ends proven or dead, never merely dropped. CAS-bound like every write.', + inputSchema: { + type: 'object', + properties: Object.assign({}, WRITE_ENVELOPE_PROPS, { + id: { type: 'string', minLength: 1 }, + outcome: { type: 'string', enum: ['tested', 'killed'] }, + evidence: { type: 'string', minLength: 1, description: 'The result that settled it.' }, + }), + required: [...WRITE_ENVELOPE_KEYS, 'id', 'outcome', 'evidence'], + additionalProperties: false, + }, + outputSchema: { + oneOf: [ + writeSuccessBranch({ assumptionId: { type: 'string' }, status: { enum: ['tested', 'killed'] } }), + WRITE_ERROR_BRANCH, + ], + }, + annotations: WRITE_DESTRUCTIVE, +}); + +const COMPILE_DONE_USAGE = + 'compile.done requires exactly: workspaceHandle, expectedStateRev, expectedStateGen, operationId'; + +const COMPILE_DONE_TOOL = Object.freeze({ + name: 'compile.done', + title: 'Checkpoint the Torque session state', + description: + 'Mark the session state CHECKPOINTED on an opened workspace: clear dirty and stamp lastCompileAt in one move. A checkpoint says the record is current, never that the work is finished. CAS-bound like every write.', + inputSchema: { + type: 'object', + properties: Object.assign({}, WRITE_ENVELOPE_PROPS), + required: [...WRITE_ENVELOPE_KEYS], + additionalProperties: false, + }, + outputSchema: { + oneOf: [ + writeSuccessBranch({ checkpointed: { const: true }, lastCompileAt: { type: 'string' } }), + WRITE_ERROR_BRANCH, + ], + }, + annotations: WRITE_DESTRUCTIVE, +}); + +const ARTIFACT_ADD_USAGE = + 'artifact.add requires exactly: workspaceHandle, expectedStateRev, expectedStateGen, operationId, item (an object)'; + +const ARTIFACT_ADD_TOOL = Object.freeze({ + name: 'artifact.add', + title: 'Record or revise a Torque artifact', + description: + 'Record an artifact ({title, kind, path, holes, revises}) on an opened workspace, or revise the one an existing id names. Terminal statuses and lifecycle fields are never accepted as input — they are earned by gated verbs (artifact.close, artifact.retract). An identical revision is a no-op: no revision bump, no proof invalidated. CAS-bound like every write.', + inputSchema: { + type: 'object', + properties: Object.assign({}, WRITE_ENVELOPE_PROPS, { + item: { + type: 'object', + description: 'The artifact payload. An existing id revises that artifact; kind is immutable; a probe is born with its disposal hole.', + }, + }), + required: [...WRITE_ENVELOPE_KEYS, 'item'], + additionalProperties: false, + }, + outputSchema: { + oneOf: [ + writeSuccessBranch({ + artifactId: { type: 'string' }, + artifactRev: { type: 'integer' }, + action: { type: 'string', enum: ['created', 'revised', 'unchanged'] }, + }), + WRITE_ERROR_BRANCH, + ], + }, + // A revision overwrites the mutable fields and invalidates proof bound to + // the previous revision — not additive. + annotations: WRITE_DESTRUCTIVE, +}); + +const ARTIFACT_CLOSE_USAGE = + 'artifact.close requires exactly: workspaceHandle, expectedStateRev, expectedStateGen, operationId, id (non-empty)'; + +const ARTIFACT_CLOSE_TOOL = Object.freeze({ + name: 'artifact.close', + title: 'Close an artifact against its bound proof', + description: + 'Close one artifact on an opened workspace — only when a KEEP proof is bound to this exact revision and hash, no open defects are attached, and no holes remain. There are no waiver arguments on this wire: record-scope proof and holes-waived closure require named human authorization and stay CLI acts. A second close of a certified artifact is a no-op. CAS-bound like every write.', + inputSchema: { + type: 'object', + properties: Object.assign({}, WRITE_ENVELOPE_PROPS, { + id: { type: 'string', minLength: 1 }, + }), + required: [...WRITE_ENVELOPE_KEYS, 'id'], + additionalProperties: false, + }, + outputSchema: { + oneOf: [ + writeSuccessBranch({ + artifactId: { type: 'string' }, + artifactRev: { type: 'integer' }, + status: { const: 'closed' }, + }), + WRITE_ERROR_BRANCH, + ], + }, + annotations: WRITE_DESTRUCTIVE, +}); + +const ARTIFACT_RETRACT_USAGE = + 'artifact.retract requires exactly: workspaceHandle, expectedStateRev, expectedStateGen, operationId, id, reason (non-empty), optionally supersededBy (non-empty)'; + +const ARTIFACT_RETRACT_TOOL = Object.freeze({ + name: 'artifact.retract', + title: 'Retract an artifact whose claim is false or obsolete', + description: + 'Retract one artifact on an opened workspace, keeping the record for provenance. Never silent: a reason is required, and a probe exit must state its outcome — reason starts "disposed:" (code reverted, finding recorded) or "promoted:" with supersededBy naming the recorded build-for-keep that replaced it. CAS-bound like every write.', + inputSchema: { + type: 'object', + properties: Object.assign({}, WRITE_ENVELOPE_PROPS, { + id: { type: 'string', minLength: 1 }, + reason: { type: 'string', minLength: 1, description: 'Why the claim is false or obsolete. Probe exits start with "disposed:" or "promoted:".' }, + supersededBy: { type: 'string', minLength: 1, description: 'The artifact that replaced this one. Required when a probe is promoted.' }, + }), + required: [...WRITE_ENVELOPE_KEYS, 'id', 'reason'], + additionalProperties: false, + }, + outputSchema: { + oneOf: [ + writeSuccessBranch({ + artifactId: { type: 'string' }, + status: { const: 'retracted' }, + supersededBy: { type: ['string', 'null'] }, + }), + WRITE_ERROR_BRANCH, + ], + }, + annotations: WRITE_DESTRUCTIVE, +}); + +const APERTURE_DIMENSION_KEYS = Object.freeze(['ambiguity', 'terrain', 'taste', 'blastRadius', 'reversibility']); + +const SCORE_APERTURE_USAGE = + 'score.aperture requires exactly: workspaceHandle, expectedStateRev, expectedStateGen, operationId, ambiguity, terrain, taste, blastRadius, reversibility (each an integer 0-2)'; + +function apertureDimensionProps() { + const props = {}; + for (const key of APERTURE_DIMENSION_KEYS) { + props[key] = { type: 'integer', minimum: 0, maximum: 2 }; + } + return props; +} + +const SCORE_APERTURE_TOOL = Object.freeze({ + name: 'score.aperture', + title: 'Meter loop depth from uncertainty', + description: + 'Score the five uncertainty dimensions (each 0-2) into an aperture level A0-A4 with the ratchet sequence to run at that depth. The read that writes: when mapRequired fires and no fog is on the record yet, the fog is serialized as an open loop in the same transaction — so this names the revision and generation it decided against like every write. A stale refusal means the world moved; re-read and re-score.', + inputSchema: { + type: 'object', + properties: Object.assign({}, WRITE_ENVELOPE_PROPS, apertureDimensionProps()), + required: [...WRITE_ENVELOPE_KEYS, ...APERTURE_DIMENSION_KEYS], + additionalProperties: false, + }, + outputSchema: { + oneOf: [ + writeSuccessBranch({ + score: { type: 'integer', minimum: 0, maximum: 10 }, + level: { type: 'string', enum: ['A0', 'A1', 'A2', 'A3', 'A4'] }, + name: { type: 'string' }, + implement: { type: 'boolean' }, + sequence: { type: 'array', items: { type: 'string' } }, + mapRequired: { type: 'boolean' }, + dimensions: { + type: 'object', + properties: apertureDimensionProps(), + required: [...APERTURE_DIMENSION_KEYS], + additionalProperties: false, + }, + scope: { type: 'string' }, + recordedFog: { type: 'boolean' }, + }), + WRITE_ERROR_BRANCH, + ], + }, + annotations: { + readOnlyHint: false, + // The only write it performs is additive: one fog loop, first racer wins. + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, +}); + +// The one sentence each refusal speaks. A table, so the funnel test can assert +// every wire sentence against this allowlist — no verb-specific catch can leak +// a path, an errno, or a store location. +const WRITE_REFUSALS = Object.freeze({ + StateNotInitialized: 'workspace state record does not exist — re-open the workspace once it is reinitialized', + StaleGeneration: 'workspace record generation changed since it was read — re-open the workspace and re-decide', + StaleStateRev: 'workspace record moved since it was read — re-read the state and re-decide against the current revision', + OperationIdConflict: 'operationId was already used for a different operation — mint a fresh operationId', + DeterministicIdConflict: 'derived record id already names an existing record — the operation refuses to address it', + UnknownRecordId: 'no record with that id exists in the target collection — re-read the state and re-decide', + ArtifactClosed: 'artifact is closed — closure is a historical fact; record a new artifact naming it in "revises", or retract it', + ClosureBlocked: 'artifact closure is blocked — bound proof, open defects, holes, or a damaged proof record stand in the way; the confidence read names each blocker', + HumanAuthorityRequired: 'this closure needs named human authorization (record-scope proof or waived holes) — it has no wire spelling; run it from the CLI', + RetractRefused: 'retraction refused — a probe exit states its outcome (reason starts "disposed:" or "promoted:"), and a promotion names a recorded non-probe replacement', + WriteFailed: 'workspace write could not be completed', +}); + +function writeRefusal(error, fields) { + const structured = Object.assign({ ok: false, error, message: WRITE_REFUSALS[error] }, fields || {}); + return { + content: [{ type: 'text', text: JSON.stringify(structured) }], + structuredContent: structured, + isError: true, + }; +} + +// THE funnel: every throwable a write path produces that is not already a +// boundary refusal becomes one allowlisted sentence. The raw error carries +// store pathnames and filesystem codes; neither belongs on this wire. +function safeWriteError(_error) { + return writeRefusal('WriteFailed'); +} + +// Envelope shape is boundary business: exactly the envelope plus this verb's +// semantic fields, every one present, none extra, each envelope field typed. +// The message names the fields (that reveals no authority); the handle itself +// is still validated by resolveHandle's one non-enumerating answer. +function writeArguments(arguments_, semanticKeys, usage, optionalKeys) { + const args = arguments_; + if (!args || typeof args !== 'object' || Array.isArray(args)) throw rpc.rpcError(-32602, usage); + const required = [...WRITE_ENVELOPE_KEYS, ...semanticKeys]; + const allowedSet = new Set([...required, ...(optionalKeys || [])]); + for (const key of Object.keys(args)) { + if (!allowedSet.has(key)) throw rpc.rpcError(-32602, usage); + } + for (const key of required) { + if (!Object.prototype.hasOwnProperty.call(args, key)) throw rpc.rpcError(-32602, usage); + } + if (!Number.isInteger(args.expectedStateRev) || args.expectedStateRev < 0) { + throw rpc.rpcError(-32602, usage); + } + if (typeof args.expectedStateGen !== 'string') throw rpc.rpcError(-32602, usage); + if (typeof args.operationId !== 'string' || !OPERATION_ID.test(args.operationId)) { + throw rpc.rpcError(-32602, usage); + } + return args; +} + +// A semantic string the schema calls non-empty: whitespace does not count as +// evidence, an owner, or a trigger. +function nonEmpty(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +// ONE outcome mapping for every write tool. The verb supplies its meaning +// (`apply`) and its semantic arguments; everything past the boundary happens +// inside ops.executeWrite's single locked transaction, and every outcome — +// success, replay, or refusal — leaves as a conforming structured result. +function runWrite(record, tool, args, semanticArgs, apply, alsoLockFile) { + let outcome; + try { + outcome = ops.executeWrite({ + state, + root: record.root, + tool, + operationId: args.operationId, + expectedStateRev: args.expectedStateRev, + expectedStateGen: args.expectedStateGen, + semanticArgs, + apply, + alsoLockFile, + }); + } catch (error) { + return safeWriteError(error); + } + switch (outcome.kind) { + case 'replayed': + return toolResult(Object.assign(outcome.result, { replayed: true })); + case 'committed': + case 'noop': + return toolResult(outcome.result); + case 'stateMissing': + return writeRefusal('StateNotInitialized', { actualStateRev: null, actualStateGen: null }); + case 'staleGen': + return writeRefusal('StaleGeneration', { + expectedStateGen: args.expectedStateGen, + actualStateGen: outcome.actualStateGen, + }); + case 'staleRev': + return writeRefusal('StaleStateRev', { + expectedStateRev: args.expectedStateRev, + actualStateRev: outcome.actualStateRev, + }); + case 'conflict': + return writeRefusal('OperationIdConflict'); + case 'idConflict': + return writeRefusal('DeterministicIdConflict'); + case 'unknownId': + return writeRefusal('UnknownRecordId'); + case 'artifactClosed': + return writeRefusal('ArtifactClosed'); + case 'closureBlocked': + return writeRefusal('ClosureBlocked'); + case 'humanAuthority': + return writeRefusal('HumanAuthorityRequired'); + case 'retractRefused': + return writeRefusal('RetractRefused'); + default: + return writeRefusal('WriteFailed'); + } +} + const RESOURCE_TEMPLATES = Object.freeze(RESOURCE_NAMES.map((name) => Object.freeze({ uriTemplate: `torque://workspace/{workspaceHandle}/${name}`, name: `torque-${name}`, @@ -346,6 +899,10 @@ function rankFriction(arguments_) { function createServer(options) { const opts = options || {}; + // Write capability is declared at spawn, never inferred: without the opt-in + // the write tools are not registered at all, so tools/list stays truthful + // instead of advertising capability the operator never granted. + const writeEnabled = opts.write === true; const roots = workspace.createRoots(opts.roots); const discoverer = repository.createDiscovery({ roots }); const registry = handles.createRegistry({ roots }); @@ -423,7 +980,9 @@ function createServer(options) { token = authority.grant({ path: found.root, kind: 'directory', - operations: ['read', 'list'], + // The grant carries what the LAUNCH granted: write authority + // exists on a handle only when the server was started with it. + operations: writeEnabled ? ['read', 'write', 'list'] : ['read', 'list'], }); } catch (error) { return toolError(safeOpenError(error)); @@ -448,6 +1007,9 @@ function createServer(options) { repositoryId: record.repositoryId, worktreeId: record.worktreeId, stateRev: snapshot && Number.isInteger(snapshot.rev) ? snapshot.rev : 0, + // The same loaded snapshot as stateRev: a revision and a generation + // read separately could describe two different records. + stateGen: snapshot ? String(snapshot.gen || '') : '', resources: uris, }; return { @@ -466,14 +1028,14 @@ function createServer(options) { // who may read. Malformed, unknown, stale, revoked, closed and // foreign-connection handles all leave here as one refusal: a reply that // varied would let the registry be enumerated one guess at a time. - function resolveHandle(handle) { + function resolveHandle(handle, operation) { if (typeof handle !== 'string' || !HANDLE_EXACT.test(handle)) { throw rpc.rpcError(-32602, RESOURCE_UNAVAILABLE); } const record = byHandle.get(handle); if (!record) throw rpc.rpcError(-32602, RESOURCE_UNAVAILABLE); try { - const granted = authority.use(handle, 'read'); + const granted = authority.use(handle, operation || 'read'); if (granted.kind !== 'directory' || granted.path !== record.root) { throw rpc.rpcError(-32602, RESOURCE_UNAVAILABLE); } @@ -534,14 +1096,194 @@ function createServer(options) { } } + // The write verbs. Envelope and semantics are refused at the boundary; + // everything past resolveHandle happens inside ops.executeWrite's single + // locked transaction (via runWrite's one outcome mapping), and each verb's + // meaning lives in src/verbs.js, shared with the CLI — one implementation, + // two boundaries. + function stateSet(arguments_) { + const args = writeArguments(arguments_, ['key', 'value'], STATE_SET_USAGE); + if (typeof args.key !== 'string' || !schemas.STATE_SCALARS.has(args.key)) { + throw rpc.rpcError(-32602, `state.set key must be one of: ${[...schemas.STATE_SCALARS].join(', ')}`); + } + if (typeof args.value !== 'string') throw rpc.rpcError(-32602, STATE_SET_USAGE); + const record = resolveHandle(args.workspaceHandle, 'write'); + return runWrite(record, 'state.set', args, { key: args.key, value: args.value }, (s, mintId) => { + verbs.setScalar(s, args.key, args.value, mintId); + return { key: args.key }; + }); + } + + function stateAppend(arguments_) { + const args = writeArguments(arguments_, ['collection', 'item'], STATE_APPEND_USAGE); + if (typeof args.collection !== 'string' || !APPENDABLE.includes(args.collection)) { + throw rpc.rpcError(-32602, + `state.append collection must be one of: ${APPENDABLE.join(', ')} — ` + + 'artifacts and defects have gated constructors (artifact.add, the CLI defect verbs)'); + } + const item = args.item; + if (!item || typeof item !== 'object' || Array.isArray(item)) { + throw rpc.rpcError(-32602, STATE_APPEND_USAGE); + } + if (item.id !== undefined && !nonEmpty(item.id)) throw rpc.rpcError(-32602, STATE_APPEND_USAGE); + // Birth status is a fact of the collection, not a request field. The + // shared verb re-checks; refusing here keeps a claimed status from ever + // entering the transaction. + const birth = verbs.BIRTH_STATUS[args.collection]; + if (birth && item.status != null && String(item.status) !== birth) { + throw rpc.rpcError(-32602, + `state.append ${args.collection} are born "${birth}" — reaching any other status is a transition verb, not a birth field`); + } + const record = resolveHandle(args.workspaceHandle, 'write'); + return runWrite(record, 'state.append', args, { collection: args.collection, item }, (s, mintId) => { + const appended = verbs.appendItem(s, args.collection, item, mintId); + if (appended.dup) return { collection: args.collection, recordId: String(appended.dup.id), deduped: true }; + return { collection: args.collection, recordId: String(appended.record.id), deduped: false }; + }); + } + + function openLoopClose(arguments_) { + const args = writeArguments(arguments_, ['id', 'evidence'], OPEN_LOOP_CLOSE_USAGE); + if (!nonEmpty(args.id) || !nonEmpty(args.evidence)) throw rpc.rpcError(-32602, OPEN_LOOP_CLOSE_USAGE); + const record = resolveHandle(args.workspaceHandle, 'write'); + return runWrite(record, 'open_loop.close', args, { id: args.id, evidence: args.evidence }, (s, mintId) => { + verbs.closeRecord(s, 'openLoops', args.id, { evidence: args.evidence }, mintId); + return { openLoopId: args.id, status: 'closed' }; + }); + } + + function openLoopPark(arguments_) { + const args = writeArguments(arguments_, ['id', 'owner', 'revisitTrigger'], OPEN_LOOP_PARK_USAGE); + if (!nonEmpty(args.id) || !nonEmpty(args.owner) || !nonEmpty(args.revisitTrigger)) { + throw rpc.rpcError(-32602, OPEN_LOOP_PARK_USAGE); + } + const record = resolveHandle(args.workspaceHandle, 'write'); + return runWrite(record, 'open_loop.park', args, + { id: args.id, owner: args.owner, revisitTrigger: args.revisitTrigger }, (s, mintId) => { + verbs.closeRecord(s, 'openLoops', args.id, + { park: true, owner: args.owner, revisitTrigger: args.revisitTrigger }, mintId); + return { openLoopId: args.id, status: 'parked' }; + }); + } + + function assumptionClose(arguments_) { + const args = writeArguments(arguments_, ['id', 'outcome', 'evidence'], ASSUMPTION_CLOSE_USAGE); + if (!nonEmpty(args.id) || !nonEmpty(args.evidence)) throw rpc.rpcError(-32602, ASSUMPTION_CLOSE_USAGE); + if (args.outcome !== 'tested' && args.outcome !== 'killed') { + throw rpc.rpcError(-32602, ASSUMPTION_CLOSE_USAGE); + } + const record = resolveHandle(args.workspaceHandle, 'write'); + return runWrite(record, 'assumption.close', args, + { id: args.id, outcome: args.outcome, evidence: args.evidence }, (s, mintId) => { + verbs.closeRecord(s, 'assumptions', args.id, + { outcome: args.outcome, evidence: args.evidence }, mintId); + return { assumptionId: args.id, status: args.outcome }; + }); + } + + function markCompileDone(arguments_) { + const args = writeArguments(arguments_, [], COMPILE_DONE_USAGE); + const record = resolveHandle(args.workspaceHandle, 'write'); + return runWrite(record, 'compile.done', args, {}, (s, mintId) => { + const at = verbs.compileDone(s, mintId); + return { checkpointed: true, lastCompileAt: at }; + }); + } + + function artifactAdd(arguments_) { + const args = writeArguments(arguments_, ['item'], ARTIFACT_ADD_USAGE); + const item = args.item; + if (!item || typeof item !== 'object' || Array.isArray(item)) { + throw rpc.rpcError(-32602, ARTIFACT_ADD_USAGE); + } + if (item.id !== undefined && !nonEmpty(item.id)) throw rpc.rpcError(-32602, ARTIFACT_ADD_USAGE); + // Terminal statuses and reserved lifecycle fields are static shape rules, + // so they refuse at the boundary; the shared verb re-checks underneath. + // The messages echo only client-supplied values, never server state. + try { + artifacts.assertArtifactInput(item); + } catch (error) { + throw rpc.rpcError(-32602, error.message); + } + const record = resolveHandle(args.workspaceHandle, 'write'); + return runWrite(record, 'artifact.add', args, { item }, (s, mintId) => { + const res = artifacts.applyAdd(s, item, mintId); + return { artifactId: String(res.record.id), artifactRev: res.record.rev, action: res.action }; + }); + } + + function artifactClose(arguments_) { + const args = writeArguments(arguments_, ['id'], ARTIFACT_CLOSE_USAGE); + if (!nonEmpty(args.id)) throw rpc.rpcError(-32602, ARTIFACT_CLOSE_USAGE); + const record = resolveHandle(args.workspaceHandle, 'write'); + // No waiver arguments cross this wire (opts stays {}), so record-scope + // proof and open holes refuse in the shared gate. The journal lock spans + // the whole transaction, commit included — same contract as the CLI. + return runWrite(record, 'artifact.close', args, { id: args.id }, (s, mintId) => { + const res = artifacts.applyClose(record.root, s, args.id, {}, mintId); + const artifactRev = res.already ? res.artifact.closedRev : res.fp.rev; + return { artifactId: args.id, artifactRev, status: 'closed' }; + }, journal.logPath(record.root)); + } + + function artifactRetract(arguments_) { + const args = writeArguments(arguments_, ['id', 'reason'], ARTIFACT_RETRACT_USAGE, ['supersededBy']); + if (!nonEmpty(args.id) || !nonEmpty(args.reason)) throw rpc.rpcError(-32602, ARTIFACT_RETRACT_USAGE); + if (args.supersededBy !== undefined && !nonEmpty(args.supersededBy)) { + throw rpc.rpcError(-32602, ARTIFACT_RETRACT_USAGE); + } + // Absent and present-but-null are one meaning; normalizing before the + // binding hash keeps a verbatim retry hashing identically either way. + const supersededBy = args.supersededBy === undefined ? null : args.supersededBy; + const record = resolveHandle(args.workspaceHandle, 'write'); + return runWrite(record, 'artifact.retract', args, + { id: args.id, reason: args.reason, supersededBy }, (s, mintId) => { + artifacts.applyRetract(s, args.id, + { reason: args.reason, supersededBy: supersededBy || '' }, mintId); + return { artifactId: args.id, status: 'retracted', supersededBy }; + }); + } + + function scoreAperture(arguments_) { + const args = writeArguments(arguments_, [...APERTURE_DIMENSION_KEYS], SCORE_APERTURE_USAGE); + const dims = {}; + for (const key of APERTURE_DIMENSION_KEYS) { + const v = args[key]; + if (!Number.isInteger(v) || v < 0 || v > 2) throw rpc.rpcError(-32602, SCORE_APERTURE_USAGE); + dims[key] = v; + } + const record = resolveHandle(args.workspaceHandle, 'write'); + // The score itself is pure; the conditional fog write is the reason this + // rides the write envelope. recordedFog is truthful on both outcomes: a + // score that wrote nothing commits nothing (noop, committed:false). + const result = scoring.scoreAperture(dims); + return runWrite(record, 'score.aperture', args, dims, (s, mintId) => { + const recorded = verbs.recordFog(s, result, mintId); + return Object.assign({}, result, { recordedFog: recorded }); + }); + } + // ONE registry. tools/list renders it and tools/call dispatches from it, so // a listed tool cannot silently lack an implementation and an implemented - // tool cannot stay undiscoverable. The order is the advertised order. + // tool cannot stay undiscoverable. The order is the advertised order; the + // write roster exists only when the launch granted writes. const toolRegistry = [ { descriptor: TOOL, run: openWorkspace }, { descriptor: SCAN_TOOL, run: scanWorkspace }, { descriptor: CONFIDENCE_TOOL, run: confidenceForWorkspace }, { descriptor: FRICTION_TOOL, run: rankFriction }, + ...(writeEnabled ? [ + { descriptor: STATE_SET_TOOL, run: stateSet }, + { descriptor: STATE_APPEND_TOOL, run: stateAppend }, + { descriptor: OPEN_LOOP_CLOSE_TOOL, run: openLoopClose }, + { descriptor: OPEN_LOOP_PARK_TOOL, run: openLoopPark }, + { descriptor: ASSUMPTION_CLOSE_TOOL, run: assumptionClose }, + { descriptor: COMPILE_DONE_TOOL, run: markCompileDone }, + { descriptor: ARTIFACT_ADD_TOOL, run: artifactAdd }, + { descriptor: ARTIFACT_CLOSE_TOOL, run: artifactClose }, + { descriptor: ARTIFACT_RETRACT_TOOL, run: artifactRetract }, + { descriptor: SCORE_APERTURE_TOOL, run: scoreAperture }, + ] : []), ]; function parseResource(uri) { @@ -659,4 +1401,4 @@ function createServer(options) { // becomes wire text, and a Windows-only slug collision cannot be provoked on // every platform the tests run on. Exported so the refusal text is falsifiable // everywhere, not just where the collision exists. -module.exports = { createServer, safeOpenError }; +module.exports = { createServer, safeOpenError, WRITE_REFUSALS }; diff --git a/src/schemas.js b/src/schemas.js index ed1f65c..d8577e5 100644 --- a/src/schemas.js +++ b/src/schemas.js @@ -62,6 +62,13 @@ function newState(clock) { openLoops: [], // { id, at, text, status } touchedFiles: [], // { path, at } history: [], // { id, at, event, note } + // Operation receipts for boundary-retried writes (MCP step 4). They live + // INSIDE this record because a receipt is only truthful if it is durable + // atomically with the commit it describes — a sibling file can be lost or + // land without its commit, and either way the receipt lies about whether + // the write happened. Bounded ring: entries are { id: operationId, tool, + // argsHash, gen, rev, at, result }, appended only when a write commits. + operations: [], }; } diff --git a/src/state.js b/src/state.js index 1d6bb7a..db3a821 100644 --- a/src/state.js +++ b/src/state.js @@ -297,9 +297,12 @@ function rejectUnusable(file, raw, why) { const WRITER_AGENTS = new Set(['scribe']); // Returns the propose-only agent name if one is active, else ''. The main caller -// (RATCHET_AGENT unset) and the scribe both return '' — they may write. -function proposeOnlyAgent() { - const a = (process.env.RATCHET_AGENT || '').trim().toLowerCase(); +// (RATCHET_AGENT unset) and the scribe both return '' — they may write. `env` is +// injectable so a launch-time guard judging an INJECTED environment (the MCP +// entry point's io.env) asks this one function instead of growing a second copy +// of the role rule that could drift from it. +function proposeOnlyAgent(env) { + const a = (((env || process.env).RATCHET_AGENT) || '').trim().toLowerCase(); return a && !WRITER_AGENTS.has(a) ? a : ''; } diff --git a/src/verbs.js b/src/verbs.js new file mode 100644 index 0000000..4f40e82 --- /dev/null +++ b/src/verbs.js @@ -0,0 +1,177 @@ +'use strict'; + +// Domain verbs shared by the CLI and the MCP write tools. One implementation, +// two boundaries: the CLI router and the MCP server both dispatch INTO these, +// so the two surfaces cannot drift into two meanings for one verb. Extracted +// verb-by-verb as MCP step 4 ships each tool (spec: 2026-07-31 write tools); +// verbs the MCP surface does not expose stay inline in cli.js. +// +// A verb takes the OPEN transaction's state object and mutates it. It never +// loads, saves, locks, or checks revisions — that is the boundary's job. The +// id minter is a parameter because the two boundaries mint differently: the +// CLI has no retry, so ids are random (state.makeId); an MCP write can be +// retried across a crash, so ids derive from the operation and re-application +// converges on the same record instead of duplicating it. +// +// Traced by: claude-fable-5 + +const schemas = require('./schemas'); + +function coerceScalar(key, value) { + if (key === 'dirty') return value === 'true' || value === true; + if (key === 'confidence') return value === '' ? null : Number(value); + return value; +} + +// `state set`: assign one settable scalar, mark the session dirty, and record +// the assignment in history. Key validation is the caller's (both boundaries +// refuse before entering the transaction); the mutation itself is the shared +// meaning. +function setScalar(s, key, value, mintId) { + s[key] = coerceScalar(key, value); + if (key !== 'dirty') s.dirty = true; + s.history.push({ id: mintId('hist', 'history'), at: schemas.nowIso(), event: 'state.set', note: `${key} = ${value}` }); +} + +// Birth status is forced, not accepted: an assumption born "tested" and a loop +// born "closed" are exactly the two lies that make the drain lie. +const BIRTH_STATUS = Object.freeze({ assumptions: 'untested', openLoops: 'open' }); + +// `state append`: push one record onto an appendable collection. Collection +// validation (and the artifacts/defects refusal — their constructors are +// gated) is the caller's. Same-text loops and assumptions dedup here, under +// the caller's lock against the reloaded record, so two writers appending the +// same text produce one entry, not two. Returns { record } or { dup }. +function appendItem(s, collection, item, mintId) { + const birth = BIRTH_STATUS[collection]; + if (birth) { + const claimed = item.status == null ? '' : String(item.status); + if (claimed && claimed !== birth) { + throw new Error( + `${collection} are born "${birth}", never "${claimed}" — reaching any other status is a transition ` + + `(ratchet state close ${collection} …), not a birth field` + ); + } + item.status = birth; + const key = String(item.text || '').trim().toLowerCase(); + const dup = key && (s[collection] || []).find((x) => String((x && x.text) || '').trim().toLowerCase() === key); + if (dup) return { dup }; + } + const record = { id: item.id || mintId(schemas.STATE_COLLECTIONS[collection], 'record'), at: schemas.nowIso(), ...item }; + s[collection].push(record); + s.dirty = true; + return { record }; +} + +// `state close`: transition one openLoop or assumption to its terminal status. +// Parking is a close too: it stops the nagging, but it STILL DRAINS, because a +// parked question is an unanswered question with an owner, not an answered +// one. The unknown-id throw is coded so the MCP boundary can map it to a +// structured refusal; the flag-gate throws stay CLI-only — over MCP those +// gates travel as required schema fields and refuse before the transaction. +function closeRecord(s, collection, id, opts, mintId) { + const need = (val, msg) => { + if (!val) throw new Error(msg); + return val; + }; + const record = (s[collection] || []).find((x) => x && x.id === id); + if (!record) { + const e = new Error(`no ${collection} entry with id "${id}"`); + e.code = 'ERATCHETUNKNOWNID'; + throw e; + } + const now = schemas.nowIso(); + const evidence = opts.evidence || ''; + let to; + + if (collection === 'openLoops') { + if (opts.park === true) { + const owner = need(opts.owner, 'parking a loop requires --owner ""'); + const trigger = need( + opts.revisitTrigger, + 'parking a loop requires --revisit-trigger "" — a park with no trigger is a drop' + ); + to = 'parked'; + record.owner = owner; + record.revisitTrigger = trigger; + } else { + need(evidence, 'closing a loop requires --evidence "" — no proof, no close'); + to = 'closed'; + record.evidence = evidence; + } + } else { + const outcome = opts.outcome || ''; + if (outcome !== 'tested' && outcome !== 'killed') { + throw new Error('closing an assumption requires --outcome tested|killed — an assumption ends proven or dead'); + } + need(evidence, 'closing an assumption requires --evidence ""'); + to = outcome; + record.evidence = evidence; + } + + const from = record.status || ''; + record.status = to; + record.closedAt = now; + s.dirty = true; + s.history.push({ + id: mintId('hist', 'history'), + at: now, + event: `${collection === 'openLoops' ? 'loop' : 'assumption'}.${to}`, + note: `${id}: ${from} → ${to}${evidence ? ` — ${evidence}` : ''}`, + }); + return to; +} + +// The fog-on-record check both aperture boundaries share. An already-open fog +// loop or a live unknown-map means the fog is on the record; writing a second +// loop would double the drain for one uncertainty. +function fogAlreadyOnRecord(s) { + const openFog = (s.openLoops || []).some( + (l) => l.status !== 'closed' && String(l.text || '').startsWith(schemas.FOG_LOOP_PREFIX) + ); + const liveMap = (s.artifacts || []).some( + (a) => a.kind === 'unknown-map' && a.status !== 'retracted' && a.status !== 'superseded' + ); + return openFog || liveMap; +} + +// `score aperture`'s conditional write: serialize the fog the moment the dial +// names it. The check that DECIDES runs here, against the state the caller's +// lock reloaded — first racer wins, the loser writes nothing. Returns true +// only when a fog loop was actually written. +function recordFog(s, result, mintId) { + if (!result.mapRequired || fogAlreadyOnRecord(s)) return false; + const now = schemas.nowIso(); + s.openLoops.push({ + id: mintId('loop', 'record'), + at: now, + text: `${schemas.FOG_LOOP_PREFIX} (aperture ${result.level}, score ${result.score}/10) — run /ratchet:map; closes when the unknown-map artifact lands`, + status: 'open', + }); + s.dirty = true; + s.history.push({ id: mintId('hist', 'history'), at: now, event: 'fog.recorded', note: `aperture ${result.level} raised mapRequired` }); + return true; +} + +// `compile done`: atomically mark the session CHECKPOINTED. Unlike a scalar +// set this CLEARS dirty and stamps lastCompileAt in one move — a checkpoint +// says the record is current, never that the work is finished. Returns the +// stamp so both boundaries report the time that was actually recorded. +function compileDone(s, mintId) { + const now = schemas.nowIso(); + s.lastCompileAt = now; + s.dirty = false; + s.history.push({ id: mintId('hist', 'history'), at: now, event: 'compile.done', note: 'state serialized' }); + return now; +} + +module.exports = { + coerceScalar, + setScalar, + BIRTH_STATUS, + appendItem, + closeRecord, + compileDone, + fogAlreadyOnRecord, + recordFog, +}; diff --git a/test/fixtures/mcp-tools-list.json b/test/fixtures/mcp-tools-list.json index ca04cd0..bf8b538 100644 --- a/test/fixtures/mcp-tools-list.json +++ b/test/fixtures/mcp-tools-list.json @@ -31,6 +31,9 @@ "stateRev": { "type": "integer" }, + "stateGen": { + "type": "string" + }, "resources": { "type": "object", "properties": { @@ -57,6 +60,7 @@ "repositoryId", "worktreeId", "stateRev", + "stateGen", "resources" ], "additionalProperties": false diff --git a/test/mcp-write.test.js b/test/mcp-write.test.js new file mode 100644 index 0000000..bc2aca6 --- /dev/null +++ b/test/mcp-write.test.js @@ -0,0 +1,1558 @@ +'use strict'; + +// Torque MCP step 4 (safe core): the write envelope on the state.set canary. +// Run: node test/mcp-write.test.js +// +// What this suite exists to prove, in the spec's words: a retried write can +// never apply twice, never claim an outcome it does not have, and survives a +// server restart. The five crash-boundary replay tests are W11 (lost +// response), W21 (process death before commit — a real child process dies at +// the rename), W20 (reconnect replay over the real wire, new handle), W12 +// (binding conflict) and W14/W15 (eviction, reset, out-of-band recreation). +// Every refusal is also a zero-byte proof: the store's bytes are snapshotted +// around it. +// +// Traced by: claude-fable-5 + +const assert = require('assert'); +const childProcess = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const tmp = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'ratchet-mcp-write-test-')) +); +process.env.RATCHET_DATA_DIR = path.join(tmp, 'state-store'); +process.env.RATCHET_EVOLVE_LOG = path.join(tmp, 'evolve-log.jsonl'); + +const mcp = require('../src/mcp/server'); +const ops = require('../src/mcp/ops'); +const state = require('../src/state'); +const schemas = require('../src/schemas'); + +const META = 'io.modelcontextprotocol/'; +const MODERN = '2026-07-28'; +const LEGACY = '2025-11-25'; +const BIN = path.join(__dirname, '..', 'bin', 'ratchet-mcp'); +const RATCHET = path.join(__dirname, '..', 'bin', 'ratchet'); + +let passed = 0; +const failures = []; +function ok(name, fn) { + try { + fn(); + passed++; + process.stdout.write(` ok ${name}\n`); + } catch (e) { + failures.push(name); + process.stdout.write(` FAIL ${name}\n ${e && e.message ? e.message : e}\n`); + } +} + +let fixtureNumber = 0; +function fixture(label) { + const dir = path.join(tmp, `${label}-${fixtureNumber++}`); + fs.mkdirSync(dir, { recursive: true }); + return fs.realpathSync.native(dir); +} + +function cleanGitEnv() { + const env = Object.assign({}, process.env); + for (const key of Object.keys(env)) { + if (key.toUpperCase().startsWith('GIT_')) delete env[key]; + } + return env; +} + +function git(cwd, args) { + return childProcess.execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: cleanGitEnv(), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); +} + +function initRepo(label) { + const dir = fixture(label); + git(dir, ['init', '--quiet']); + return dir; +} + +function service(roots, write) { + return mcp.createServer({ + roots, + write, + serverInfo: { name: 'torque-mcp-test', version: '0.0.0' }, + }); +} + +function modernMeta() { + return { + [META + 'protocolVersion']: MODERN, + [META + 'clientCapabilities']: {}, + [META + 'clientInfo']: { name: 'test-client', version: '0' }, + }; +} + +let requestId = 0; +function modern(conn, method, params) { + return conn.handleMessage({ + jsonrpc: '2.0', + id: ++requestId, + method, + params: { ...(params || {}), _meta: modernMeta() }, + }); +} + +function initialize(conn) { + return conn.handleMessage({ + jsonrpc: '2.0', + id: ++requestId, + method: 'initialize', + params: { + protocolVersion: LEGACY, + capabilities: {}, + clientInfo: { name: 'test-client', version: '0' }, + }, + }); +} + +function legacy(conn, method, params) { + return conn.handleMessage({ + jsonrpc: '2.0', + id: ++requestId, + method, + params: params || {}, + }); +} + +function callTool(conn, era, name, arguments_) { + const call = era === 'legacy' ? legacy : modern; + return call(conn, 'tools/call', { name, arguments: arguments_ }); +} + +function openWorkspace(conn, repo, era) { + return payload(callTool(conn, era, 'workspace.open', { path: repo })); +} + +// A successful tool result: no protocol error, no isError, structured === +// text — one answer on both channels. +function payload(response) { + assert.strictEqual(response.error, undefined, response.error && response.error.message); + assert.notStrictEqual(response.result.isError, true, JSON.stringify(response.result)); + const fromText = JSON.parse(response.result.content[0].text); + assert.deepStrictEqual(response.result.structuredContent, fromText, + 'structured and compatibility text results must carry one answer'); + return response.result.structuredContent; +} + +// A structured write refusal: isError, conforming error branch, one answer. +function refusal(response) { + assert.strictEqual(response.error, undefined, response.error && response.error.message); + assert.strictEqual(response.result.isError, true, JSON.stringify(response.result)); + const structured = response.result.structuredContent; + assert.deepStrictEqual(JSON.parse(response.result.content[0].text), structured); + assert.strictEqual(structured.ok, false); + assert.strictEqual(typeof structured.error, 'string'); + assert.strictEqual(structured.message, mcp.WRITE_REFUSALS[structured.error], + 'every refusal message comes from the one allowlisted table'); + return structured; +} + +function boundaryRefusal(response) { + assert.ok(response.error, `expected a protocol refusal: ${JSON.stringify(response.result)}`); + assert.strictEqual(response.error.code, -32602); + return response.error; +} + +function opId() { + return crypto.randomBytes(16).toString('base64url'); +} + +function envelopeFor(open, extra) { + return Object.assign({ + workspaceHandle: open.workspaceHandle, + expectedStateRev: open.stateRev, + expectedStateGen: open.stateGen, + operationId: opId(), + }, extra || {}); +} + +// Byte snapshot of one workspace's store directory: relative path -> hex. +// The zero-byte refusal proofs compare these before and after. +function storeSnapshot(repo) { + const dir = state.projectDir(repo); + const out = {}; + if (!fs.existsSync(dir)) return out; + const walk = (d, rel) => { + for (const name of fs.readdirSync(d)) { + const full = path.join(d, name); + const key = rel ? `${rel}/${name}` : name; + const stat = fs.lstatSync(full); + if (stat.isDirectory()) walk(full, key); + else out[key] = fs.readFileSync(full).toString('hex'); + } + }; + walk(dir, ''); + return out; +} + +function readState(repo) { + return JSON.parse(fs.readFileSync(path.join(state.projectDir(repo), 'state.json'), 'utf8')); +} + +function sleep(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +// --------------------------------------------------------------------------- +// Mechanism units: canonical encoding and deterministic ids. +// --------------------------------------------------------------------------- + +ok('U1 canonical encoding sorts keys recursively, preserves arrays, golden vectors', () => { + assert.strictEqual(ops.canonicalStringify({ b: 1, a: 2 }), '{"a":2,"b":1}'); + assert.strictEqual( + ops.canonicalStringify({ z: { y: 1, x: [3, 1, 2] }, a: '' }), + '{"a":"","z":{"x":[3,1,2],"y":1}}' + ); + assert.strictEqual(ops.canonicalStringify([{ b: 0, a: null }]), '[{"a":null,"b":0}]'); + assert.strictEqual(ops.canonicalStringify({}), '{}'); + // Non-ASCII values survive untouched (no normalization). + assert.strictEqual(ops.canonicalStringify({ k: 'Ünïcode ✓' }), '{"k":"Ünïcode ✓"}'); +}); + +ok('U2 key order is Unicode code point, not UTF-16 code unit', () => { + // U+FFFD sorts BEFORE U+1F600 by code point; default string comparison puts + // the surrogate pair (0xD83D...) first. This vector discriminates the two. + const encoded = ops.canonicalStringify({ '\u{1F600}': 1, '�': 2 }); + assert.ok(encoded.indexOf('�') < encoded.indexOf('\u{1F600}'), encoded); +}); + +ok('U3 binding hash covers meaning, not transport, and moves with every input', () => { + const base = ops.bindingHash('state.set', { key: 'objective', value: 'x' }, 3, 'gen-a'); + assert.match(base, /^sha256:[0-9a-f]{64}$/); + assert.strictEqual(base, ops.bindingHash('state.set', { value: 'x', key: 'objective' }, 3, 'gen-a'), + 'argument key order is canonicalized away'); + for (const varied of [ + ops.bindingHash('state.append', { key: 'objective', value: 'x' }, 3, 'gen-a'), + ops.bindingHash('state.set', { key: 'objective', value: 'y' }, 3, 'gen-a'), + ops.bindingHash('state.set', { key: 'objective', value: 'x' }, 4, 'gen-a'), + ops.bindingHash('state.set', { key: 'objective', value: 'x' }, 3, 'gen-b'), + ]) { + assert.notStrictEqual(base, varied); + } +}); + +ok('U4 derived ids keep 128 bits, are deterministic, and vary by role', () => { + const a = ops.deriveId('hist', 'gen-a', 'state.set', 'sha256:00', 'history'); + assert.match(a, /^hist-[0-9a-f]{32}$/); + assert.strictEqual(a, ops.deriveId('hist', 'gen-a', 'state.set', 'sha256:00', 'history')); + assert.notStrictEqual(a, ops.deriveId('hist', 'gen-a', 'state.set', 'sha256:00', 'defect')); + assert.notStrictEqual(a, ops.deriveId('hist', 'gen-b', 'state.set', 'sha256:00', 'history')); +}); + +// --------------------------------------------------------------------------- +// Discovery and the write opt-in. +// --------------------------------------------------------------------------- + +const ENVELOPE_KEYS = ['workspaceHandle', 'expectedStateRev', 'expectedStateGen', 'operationId']; +const SESSION_VERBS = ['state.append', 'open_loop.close', 'open_loop.park', 'assumption.close', 'compile.done']; +const ARTIFACT_VERBS = ['artifact.add', 'artifact.close', 'artifact.retract', 'score.aperture']; +const APERTURE_DIMS = ['ambiguity', 'terrain', 'taste', 'blastRadius', 'reversibility']; +const WRITE_ROSTER = [ + 'workspace.open', 'workspace.scan', 'score.confidence', 'score.friction', + 'state.set', ...SESSION_VERBS, ...ARTIFACT_VERBS, +]; + +ok('W1 a flagless server registers no write tools and cannot dispatch one', () => { + const repo = initRepo('w1-repo'); + const conn = service([repo], false).createConnection(); + const listed = modern(conn, 'tools/list', {}).result.tools.map((t) => t.name); + assert.deepStrictEqual(listed, ['workspace.open', 'workspace.scan', 'score.confidence', 'score.friction']); + const open = openWorkspace(conn, repo); + const response = callTool(conn, 'modern', 'state.set', envelopeFor(open, { key: 'objective', value: 'x' })); + assert.ok(response.error, 'an unregistered tool is undispatchable'); + assert.strictEqual(response.error.code, -32602); + assert.match(response.error.message, /unknown tool/); +}); + +ok('W2 a --write server advertises state.set with the pinned descriptor, both eras', () => { + const repo = initRepo('w2-repo'); + const server = service([repo], true); + const conn = server.createConnection(); + const tools = modern(conn, 'tools/list', {}).result.tools; + assert.deepStrictEqual(tools.map((t) => t.name), WRITE_ROSTER); + const descriptor = tools[4]; + assert.deepStrictEqual(descriptor.annotations, + { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }); + assert.deepStrictEqual(descriptor.inputSchema.required, + ['workspaceHandle', 'expectedStateRev', 'expectedStateGen', 'operationId', 'key', 'value']); + assert.strictEqual(descriptor.inputSchema.additionalProperties, false); + assert.strictEqual(descriptor.outputSchema.oneOf.length, 2, 'success and error branches'); + const [success, error] = descriptor.outputSchema.oneOf; + assert.deepStrictEqual(success.required, ['ok', 'committed', 'stateRev', 'replayed', 'key']); + assert.deepStrictEqual(error.required, ['ok', 'error', 'message']); + const legacyConn = server.createConnection(); + initialize(legacyConn); + const legacyTools = legacy(legacyConn, 'tools/list', {}).result.tools.map((t) => t.name); + assert.ok(legacyTools.includes('state.set'), 'the write roster exists on the legacy era too'); +}); + +ok('W3 workspace.open reports stateGen from the same snapshot as stateRev', () => { + const repo = initRepo('w3-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + assert.strictEqual(typeof open.stateGen, 'string'); + assert.ok(open.stateGen.length, 'a fresh store has a generation'); + const disk = readState(repo); + assert.strictEqual(open.stateGen, disk.gen); + assert.strictEqual(open.stateRev, disk.rev); +}); + +// --------------------------------------------------------------------------- +// The committed path. +// --------------------------------------------------------------------------- + +ok('W4 a committed write moves one revision and carries its receipt in the same commit', () => { + const repo = initRepo('w4-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + const envelope = envelopeFor(open, { key: 'objective', value: 'ship step 4' }); + const result = payload(callTool(conn, 'modern', 'state.set', envelope)); + assert.deepStrictEqual(result, + { ok: true, committed: true, stateRev: open.stateRev + 1, replayed: false, key: 'objective' }); + const disk = readState(repo); + assert.strictEqual(disk.objective, 'ship step 4'); + assert.strictEqual(disk.dirty, true); + assert.strictEqual(disk.rev, open.stateRev + 1); + assert.strictEqual(disk.history.length, 1); + assert.match(disk.history[0].id, /^hist-[0-9a-f]{32}$/, 'MCP-minted ids are derived, not random'); + assert.strictEqual(disk.operations.length, 1, 'the receipt rode the same commit'); + const receipt = disk.operations[0]; + assert.strictEqual(receipt.id, envelope.operationId); + assert.strictEqual(receipt.tool, 'state.set'); + assert.match(receipt.argsHash, /^sha256:[0-9a-f]{64}$/); + assert.strictEqual(receipt.gen, open.stateGen); + assert.strictEqual(receipt.rev, disk.rev); + assert.deepStrictEqual(receipt.result, result, 'the persisted result is the replay answer'); +}); + +ok('W5 the MCP verb and the CLI verb write the same record (ids and stamps aside)', () => { + const mcpRepo = initRepo('w5-mcp'); + const cliRepo = initRepo('w5-cli'); + const conn = service([mcpRepo], true).createConnection(); + const open = openWorkspace(conn, mcpRepo); + payload(callTool(conn, 'modern', 'state.set', envelopeFor(open, { key: 'bottleneck', value: 'the seam' }))); + childProcess.execFileSync(process.execPath, [RATCHET, 'state', 'set', 'bottleneck', 'the seam'], { + cwd: cliRepo, encoding: 'utf8', env: cleanGitEnv(), windowsHide: true, + }); + const viaMcp = readState(mcpRepo); + const viaCli = readState(cliRepo); + assert.strictEqual(viaMcp.bottleneck, viaCli.bottleneck); + assert.strictEqual(viaMcp.dirty, viaCli.dirty); + const strip = (h) => ({ event: h.event, note: h.note }); + assert.deepStrictEqual(viaMcp.history.map(strip), viaCli.history.map(strip), + 'one verb meaning on both boundaries'); + assert.match(viaMcp.history[0].id, /^hist-[0-9a-f]{32}$/); + assert.match(viaCli.history[0].id, /^hist-[0-9a-z]+-[0-9a-f]+$/, 'the CLI keeps random ids'); +}); + +ok('W5b confidence coerces to a number through the shared helper', () => { + const repo = initRepo('w5b-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + payload(callTool(conn, 'modern', 'state.set', envelopeFor(open, { key: 'confidence', value: '7' }))); + assert.strictEqual(readState(repo).confidence, 7); +}); + +// --------------------------------------------------------------------------- +// Boundary refusals: the envelope. +// --------------------------------------------------------------------------- + +ok('W6 a malformed envelope is refused at the boundary with zero bytes moved', () => { + const repo = initRepo('w6-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + const before = storeSnapshot(repo); + const good = () => envelopeFor(open, { key: 'objective', value: 'x' }); + const bad = [ + (e) => { delete e.operationId; }, + (e) => { e.operationId = 'too-short-21-chars-aa'; }, + (e) => { e.operationId = 'has spaces which are not allowed'; }, + (e) => { delete e.expectedStateRev; }, + (e) => { e.expectedStateRev = -1; }, + (e) => { e.expectedStateRev = '0'; }, + (e) => { delete e.expectedStateGen; }, + (e) => { e.expectedStateGen = 7; }, + (e) => { e.extra = true; }, + (e) => { delete e.key; }, + (e) => { e.key = 'rev'; }, + (e) => { e.value = 42; }, + ]; + for (const mutate of bad) { + const envelope = good(); + mutate(envelope); + boundaryRefusal(callTool(conn, 'modern', 'state.set', envelope)); + } + assert.deepStrictEqual(storeSnapshot(repo), before, 'no refusal moved a byte'); +}); + +ok('W7 handle authority is one non-enumerating answer on the write door', () => { + const repo = initRepo('w7-repo'); + const server = service([repo], true); + const conn = server.createConnection(); + const open = openWorkspace(conn, repo); + const fabricated = boundaryRefusal(callTool(conn, 'modern', 'state.set', + envelopeFor({ ...open, workspaceHandle: 'A'.repeat(43) }, { key: 'objective', value: 'x' }))); + const foreignConn = server.createConnection(); + const foreign = boundaryRefusal(callTool(foreignConn, 'modern', 'state.set', + envelopeFor(open, { key: 'objective', value: 'x' }))); + assert.strictEqual(fabricated.message, foreign.message, + 'fabricated and foreign handles get one answer'); +}); + +// --------------------------------------------------------------------------- +// Domain refusals: CAS over revision, generation, existence. +// --------------------------------------------------------------------------- + +ok('W8 a store destroyed after open refuses StateNotInitialized and creates nothing', () => { + const repo = initRepo('w8-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + fs.rmSync(path.join(state.projectDir(repo), 'state.json')); + const before = storeSnapshot(repo); + const structured = refusal(callTool(conn, 'modern', 'state.set', + envelopeFor(open, { key: 'objective', value: 'x' }))); + assert.strictEqual(structured.error, 'StateNotInitialized'); + assert.strictEqual(structured.actualStateRev, null); + assert.strictEqual(structured.actualStateGen, null); + assert.deepStrictEqual(storeSnapshot(repo), before, + 'the refusal did not mint a fresh store'); +}); + +ok('W9 a reset store refuses StaleGeneration for pre-reset envelopes', () => { + const repo = initRepo('w9-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + state.initProject(repo, { force: true, resetBy: 'test', resetReason: 'lineage test' }); + const before = storeSnapshot(repo); + const structured = refusal(callTool(conn, 'modern', 'state.set', + envelopeFor(open, { key: 'objective', value: 'pre-reset intent' }))); + assert.strictEqual(structured.error, 'StaleGeneration'); + assert.strictEqual(structured.expectedStateGen, open.stateGen); + assert.notStrictEqual(structured.actualStateGen, open.stateGen); + assert.deepStrictEqual(storeSnapshot(repo), before); + assert.notStrictEqual(readState(repo).objective, 'pre-reset intent', + 'pre-reset intent never lands'); +}); + +ok('W10 a moved revision refuses StaleStateRev, past or future, with the actual named', () => { + const repo = initRepo('w10-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + payload(callTool(conn, 'modern', 'state.set', envelopeFor(open, { key: 'objective', value: 'first' }))); + const before = storeSnapshot(repo); + const stale = refusal(callTool(conn, 'modern', 'state.set', + envelopeFor(open, { key: 'objective', value: 'second' }))); + assert.strictEqual(stale.error, 'StaleStateRev'); + assert.strictEqual(stale.expectedStateRev, open.stateRev); + assert.strictEqual(stale.actualStateRev, open.stateRev + 1); + const future = refusal(callTool(conn, 'modern', 'state.set', + envelopeFor({ ...open, stateRev: open.stateRev + 5 }, { key: 'objective', value: 'third' }))); + assert.strictEqual(future.error, 'StaleStateRev'); + assert.deepStrictEqual(storeSnapshot(repo), before); + assert.strictEqual(readState(repo).objective, 'first'); +}); + +// --------------------------------------------------------------------------- +// Crash-boundary test 1: lost response — the verbatim retry replays. +// --------------------------------------------------------------------------- + +ok('W11 a verbatim retry returns the persisted receipt and moves nothing, both eras', () => { + for (const era of ['modern', 'legacy']) { + const repo = initRepo(`w11-${era}`); + const conn = service([repo], true).createConnection(); + if (era === 'legacy') initialize(conn); + const open = openWorkspace(conn, repo, era); + const envelope = envelopeFor(open, { key: 'objective', value: 'landed' }); + const first = payload(callTool(conn, era, 'state.set', envelope)); + const before = storeSnapshot(repo); + const retry = payload(callTool(conn, era, 'state.set', envelope)); + assert.deepStrictEqual(retry, { ...first, replayed: true }, + 'the retry is the recorded outcome, marked replayed'); + assert.deepStrictEqual(storeSnapshot(repo), before, 'a replay is a pure read'); + const stored = readState(repo).operations[0]; + assert.strictEqual(stored.result.replayed, false, + 'the stored bytes keep replayed:false; decoration happens on a copy'); + } +}); + +// --------------------------------------------------------------------------- +// Crash-boundary test 4: one id, two meanings. +// --------------------------------------------------------------------------- + +ok('W12 the same operationId with a different binding refuses OperationIdConflict', () => { + const repo = initRepo('w12-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + const envelope = envelopeFor(open, { key: 'objective', value: 'meaning one' }); + payload(callTool(conn, 'modern', 'state.set', envelope)); + const before = storeSnapshot(repo); + for (const varied of [ + { ...envelope, value: 'meaning two' }, + { ...envelope, key: 'bottleneck' }, + { ...envelope, expectedStateRev: open.stateRev + 1 }, + { ...envelope, expectedStateGen: 'gen-other' }, + ]) { + const structured = refusal(callTool(conn, 'modern', 'state.set', varied)); + assert.strictEqual(structured.error, 'OperationIdConflict', JSON.stringify(varied)); + } + assert.deepStrictEqual(storeSnapshot(repo), before); + assert.strictEqual(readState(repo).operations.length, 1, 'the ring is unchanged'); +}); + +// --------------------------------------------------------------------------- +// No-ops: safety without durable observability (the stated limit). +// --------------------------------------------------------------------------- + +ok('W13 a no-op commits nothing, records no receipt, and repeats as a no-op', () => { + const repo = initRepo('w13-repo'); + state.loadState(repo); // initialize the store the runner will refuse to create + const before = storeSnapshot(repo); + const disk = readState(repo); + const call = () => ops.executeWrite({ + state, + root: repo, + tool: 'state.set', + operationId: 'noop-operation-id-0000000000', + expectedStateRev: disk.rev, + expectedStateGen: disk.gen, + semanticArgs: { key: 'objective', value: '' }, + apply: () => ({ key: 'objective' }), + }); + const first = call(); + assert.strictEqual(first.kind, 'noop'); + assert.deepStrictEqual(first.result, + { ok: true, committed: false, stateRev: disk.rev, replayed: false, key: 'objective' }); + assert.deepStrictEqual(storeSnapshot(repo), before, 'a no-op moves nothing'); + const again = call(); + assert.strictEqual(again.kind, 'noop', 'retrying a no-op re-runs it; nothing was stored to replay'); + assert.deepStrictEqual(storeSnapshot(repo), before); + assert.ok(!readState(repo).operations || !readState(repo).operations.length, + 'no receipt exists for a no-op'); +}); + +// --------------------------------------------------------------------------- +// Crash-boundary test 5: eviction, reset, recreation. +// --------------------------------------------------------------------------- + +ok('W14 an evicted receipt cannot replay — the stale refusal answers instead', () => { + const repo = initRepo('w14-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + const firstEnvelope = envelopeFor(open, { key: 'objective', value: 'v0' }); + payload(callTool(conn, 'modern', 'state.set', firstEnvelope)); + for (let i = 1; i <= ops.OPERATIONS_CAP; i++) { + payload(callTool(conn, 'modern', 'state.set', envelopeFor( + { ...open, stateRev: open.stateRev + i }, + { key: 'objective', value: `v${i}` } + ))); + } + const disk = readState(repo); + assert.strictEqual(disk.operations.length, ops.OPERATIONS_CAP, 'the ring is bounded'); + assert.ok(!disk.operations.some((e) => e.id === firstEnvelope.operationId), + 'the first receipt was evicted'); + const before = storeSnapshot(repo); + const structured = refusal(callTool(conn, 'modern', 'state.set', firstEnvelope)); + assert.strictEqual(structured.error, 'StaleStateRev', + 'a verbatim retry of an evicted operation refuses; it never re-applies'); + assert.deepStrictEqual(storeSnapshot(repo), before); + assert.strictEqual(readState(repo).objective, `v${ops.OPERATIONS_CAP}`); +}); + +ok('W15 reset wipes the ring on a continued revision line; recreation trips the generation', () => { + const repo = initRepo('w15-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + const envelope = envelopeFor(open, { key: 'objective', value: 'pre-reset' }); + payload(callTool(conn, 'modern', 'state.set', envelope)); + const revBefore = readState(repo).rev; + state.initProject(repo, { force: true, resetBy: 'test', resetReason: 'wipe' }); + const wiped = readState(repo); + assert.strictEqual(wiped.rev, revBefore + 1, 'the revision line CONTINUES across a wipe'); + assert.deepStrictEqual(wiped.operations, [], 'the ring is wiped with the record it lives in'); + const afterReset = refusal(callTool(conn, 'modern', 'state.set', envelope)); + assert.strictEqual(afterReset.error, 'StaleGeneration', 'post-reset, the old envelope refuses'); + + // Out-of-band recreation: destroy the store, rebuild it, and drive the + // revision back to the number the old envelope names. Only the generation + // can tell the two lineages apart — and it does. + fs.rmSync(state.projectDir(repo), { recursive: true, force: true }); + state.loadState(repo); // fresh store: rev 0 again, NEW generation + const rebuilt = service([repo], true).createConnection(); + const reopened = openWorkspace(rebuilt, repo); + assert.strictEqual(reopened.stateRev, envelope.expectedStateRev, + 'the recreated store reuses the numeric revision the old envelope names'); + assert.notStrictEqual(reopened.stateGen, envelope.expectedStateGen); + const recreated = refusal(callTool(conn, 'modern', 'state.set', envelope)); + assert.strictEqual(recreated.error, 'StaleGeneration', + 'a same-revision recreation still refuses the old lineage'); + assert.notStrictEqual(readState(repo).objective, 'pre-reset'); +}); + +// --------------------------------------------------------------------------- +// Deterministic-id collision: entropy is not permission to merge. +// --------------------------------------------------------------------------- + +ok('W16 a derived id colliding with an existing record refuses with zero bytes', () => { + const repo = initRepo('w16-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + // Plan the write against the revision AFTER the injection commit, derive the + // id it will mint, then plant that id first. + const plannedRev = open.stateRev + 1; + const semantic = { key: 'objective', value: 'collide' }; + const argsHash = ops.bindingHash('state.set', semantic, plannedRev, open.stateGen); + const plantedId = ops.deriveId('hist', open.stateGen, 'state.set', argsHash, 'history'); + state.withWorkspaceMutation(repo, { action: 'test-inject' }, (s) => { + s.history.push({ id: plantedId, at: schemas.nowIso(), event: 'planted', note: '' }); + }); + const before = storeSnapshot(repo); + const structured = refusal(callTool(conn, 'modern', 'state.set', + envelopeFor({ ...open, stateRev: plannedRev }, semantic))); + assert.strictEqual(structured.error, 'DeterministicIdConflict'); + assert.deepStrictEqual(storeSnapshot(repo), before); +}); + +// --------------------------------------------------------------------------- +// The receipt entry cap fails closed. +// --------------------------------------------------------------------------- + +ok('W17 an oversized result refuses before commit instead of truncating', () => { + const repo = initRepo('w17-repo'); + state.loadState(repo); + const disk = readState(repo); + const before = storeSnapshot(repo); + const outcome = ops.executeWrite({ + state, + root: repo, + tool: 'state.set', + operationId: 'oversized-result-op-00000000', + expectedStateRev: disk.rev, + expectedStateGen: disk.gen, + semanticArgs: { key: 'objective', value: 'big' }, + apply: (s) => { + s.objective = 'big'; + return { key: 'x'.repeat(ops.RECEIPT_ENTRY_CAP) }; + }, + }); + assert.strictEqual(outcome.kind, 'capOverflow'); + assert.deepStrictEqual(storeSnapshot(repo), before, + 'the aborted transaction committed nothing — not even the verb mutation'); +}); + +// --------------------------------------------------------------------------- +// The error funnel leaks nothing. +// --------------------------------------------------------------------------- + +ok('W18 every refusal sentence is allowlisted and names no path, errno, or store', () => { + for (const [code, sentence] of Object.entries(mcp.WRITE_REFUSALS)) { + assert.strictEqual(typeof sentence, 'string', code); + assert.ok(!/[\\/]/.test(sentence), `no path separators: ${sentence}`); + assert.ok(!/E[A-Z]{2,}/.test(sentence), `no errno codes: ${sentence}`); + assert.ok(!sentence.includes(tmp), 'no store locations'); + } +}); + +// --------------------------------------------------------------------------- +// The launch-time agent guard: a propose-only role may read, never --write. +// --------------------------------------------------------------------------- + +ok('W19 --write under a propose-only RATCHET_AGENT refuses at startup; reads stay open', () => { + const main = require('../src/mcp/main'); + const { PassThrough } = require('stream'); + const repo = initRepo('w19-repo'); + const io = () => { + let text = ''; + const err = new PassThrough(); + err.on('data', (d) => { text += d; }); + return { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: err, + env: { RATCHET_AGENT: 'builder' }, + read: () => text, + }; + }; + const refused = io(); + const started = main.start(['--root', repo, '--write'], refused); + assert.strictEqual(started.exitCode, 2, 'a propose-only writer is a launch error'); + assert.match(refused.read(), /RATCHET_AGENT=builder/, 'the diagnostic names the conflict'); + assert.match(refused.read(), /propose-only/); + + const allowed = io(); + const readOnly = main.start(['--root', repo], allowed); + assert.strictEqual(readOnly.exitCode, null, 'the same role launches a read-only server'); + readOnly.server && readOnly.attached && readOnly.attached.detach && readOnly.attached.detach(); + // And the read path itself stays open to that role: reads never cross + // assertMayWrite, and a plain open initializes without a wipe. + process.env.RATCHET_AGENT = 'builder'; + try { + const conn = service([repo], false).createConnection(); + const open = openWorkspace(conn, repo); + assert.ok(open.workspaceHandle, 'a propose-only agent can still orient'); + } finally { + delete process.env.RATCHET_AGENT; + } +}); + +// --------------------------------------------------------------------------- +// Crash-boundary test 2: real process death before the commit rename. +// --------------------------------------------------------------------------- + +const CRASHER = path.join(tmp, 'crash-before-commit.js'); +fs.writeFileSync(CRASHER, [ + "'use strict';", + '// Dies AT the state.json publish rename: the transaction is armed only', + '// after workspace.open, so open itself commits normally.', + 'const fs = require("fs");', + 'const [repo] = process.argv.slice(2);', + 'const mcp = require(process.env.SERVER_MODULE);', + 'const real = fs.renameSync;', + 'let armed = false;', + 'fs.renameSync = (from, to) => {', + ' if (armed && String(to).endsWith("state.json")) process.exit(41);', + ' return real(from, to);', + '};', + 'const conn = mcp.createServer({ roots: [repo], write: true }).createConnection();', + 'const meta = {', + ' "io.modelcontextprotocol/protocolVersion": "2026-07-28",', + ' "io.modelcontextprotocol/clientCapabilities": {},', + ' "io.modelcontextprotocol/clientInfo": { name: "crash-client", version: "0" },', + '};', + 'const call = (id, name, args) => conn.handleMessage({', + ' jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args, _meta: meta },', + '});', + 'const open = call(1, "workspace.open", { path: repo }).result.structuredContent;', + 'armed = true;', + 'call(2, "state.set", {', + ' workspaceHandle: open.workspaceHandle,', + ' expectedStateRev: open.stateRev,', + ' expectedStateGen: open.stateGen,', + ' operationId: process.env.CRASH_OPERATION_ID,', + ' key: "objective", value: "died mid-commit",', + '});', + 'process.exit(7); // reaching here means the failpoint never fired', +].join('\n'), 'utf8'); + +ok('W21 a process dead before the commit rename leaves nothing; the retry applies once', () => { + const repo = initRepo('w21-repo'); + const operationId = opId(); + const crashed = childProcess.spawnSync(process.execPath, [CRASHER, repo], { + encoding: 'utf8', + env: Object.assign(cleanGitEnv(), { + SERVER_MODULE: path.join(__dirname, '..', 'src', 'mcp', 'server.js'), + CRASH_OPERATION_ID: operationId, + }), + timeout: 30000, + windowsHide: true, + }); + assert.strictEqual(crashed.status, 41, `the child must die at the rename: ${crashed.stderr}`); + const disk = readState(repo); + assert.strictEqual(disk.objective, '', 'no state change survived the death'); + assert.strictEqual(disk.rev, 0, 'no revision moved'); + assert.ok(!disk.operations || !disk.operations.length, 'no receipt survived either'); + + // The dead process may still hold the workspace lock; a dead pid goes + // soft-stale, and the retry may break it. + process.env.RATCHET_LOCK_STALE_MS = '1'; + try { + sleep(30); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + const retry = payload(callTool(conn, 'modern', 'state.set', { + workspaceHandle: open.workspaceHandle, + expectedStateRev: open.stateRev, + expectedStateGen: open.stateGen, + operationId, + key: 'objective', + value: 'died mid-commit', + })); + assert.deepStrictEqual(retry, + { ok: true, committed: true, stateRev: open.stateRev + 1, replayed: false, key: 'objective' }, + 'the retry applies cleanly — the operation never happened'); + const after = readState(repo); + assert.strictEqual(after.objective, 'died mid-commit'); + assert.strictEqual(after.history.length, 1, 'exactly one application'); + } finally { + delete process.env.RATCHET_LOCK_STALE_MS; + } +}); + +// --------------------------------------------------------------------------- +// Crash-boundary test 3: reconnect replay over the real wire — the server +// process is REPLACED, the client reopens (new handle), and the verbatim +// retry still returns the receipt. Proves the binding excludes the transport. +// --------------------------------------------------------------------------- + +const RECONNECT_CLIENT = path.join(tmp, 'reconnect-client.js'); +fs.writeFileSync(RECONNECT_CLIENT, [ + "'use strict';", + 'const cp = require("child_process");', + 'const [bin, root, operationId] = process.argv.slice(2);', + 'const meta = {', + ' "io.modelcontextprotocol/protocolVersion": "2026-07-28",', + ' "io.modelcontextprotocol/clientCapabilities": {},', + ' "io.modelcontextprotocol/clientInfo": { name: "reconnect-client", version: "0" },', + '};', + 'function drive(requests, done) {', + ' const child = cp.spawn(process.execPath, [bin, "--root", root, "--write"], { windowsHide: true });', + ' let buffered = "";', + ' const seen = [];', + ' let index = 0;', + ' const send = () => child.stdin.write(JSON.stringify(requests[index](seen)) + "\\n");', + ' child.stdout.on("data", (d) => {', + ' buffered += d;', + ' let nl;', + ' while ((nl = buffered.indexOf("\\n")) !== -1) {', + ' const line = buffered.slice(0, nl); buffered = buffered.slice(nl + 1);', + ' if (!line.length) continue;', + ' seen.push(JSON.parse(line));', + ' index++;', + ' if (index < requests.length) send();', + ' else { child.kill(); done(seen); return; }', + ' }', + ' });', + ' send();', + '}', + 'const envelope = (open, id) => ({', + ' jsonrpc: "2.0", id, method: "tools/call",', + ' params: { name: "state.set", arguments: {', + ' workspaceHandle: open.workspaceHandle,', + ' expectedStateRev: open.stateRev,', + ' expectedStateGen: open.stateGen,', + ' operationId, key: "objective", value: "written before the crash",', + ' }, _meta: meta },', + '});', + 'const openReq = (id) => ({', + ' jsonrpc: "2.0", id, method: "tools/call",', + ' params: { name: "workspace.open", arguments: { path: root }, _meta: meta },', + '});', + 'drive([', + ' () => openReq(1),', + ' (seen) => envelope(seen[0].result.structuredContent, 2),', + '], (firstRun) => {', + ' const firstOpen = firstRun[0].result.structuredContent;', + ' // Server one is dead. Server two, fresh process, fresh connection state.', + ' drive([', + ' () => openReq(3),', + ' (seen) => {', + ' const reopened = seen[0].result.structuredContent;', + ' // The RETRY: new handle (the old one died with its connection), same', + ' // operationId, same revision + generation OBSERVED BEFORE the write.', + ' return envelope({', + ' workspaceHandle: reopened.workspaceHandle,', + ' stateRev: firstOpen.stateRev,', + ' stateGen: firstOpen.stateGen,', + ' }, 4);', + ' },', + ' ], (secondRun) => {', + ' process.stdout.write(JSON.stringify({', + ' firstOpen,', + ' firstWrite: firstRun[1].result,', + ' reopen: secondRun[0].result.structuredContent,', + ' retry: secondRun[1].result,', + ' }));', + ' process.exit(0);', + ' });', + '});', + 'setTimeout(() => { process.stderr.write("timeout"); process.exit(1); }, 45000);', +].join('\n'), 'utf8'); + +ok('W20 a reconnect after server death replays the receipt through a new handle', () => { + const repo = initRepo('w20-repo'); + const proc = childProcess.spawnSync( + process.execPath, + [RECONNECT_CLIENT, BIN, repo, opId()], + { encoding: 'utf8', env: cleanGitEnv(), timeout: 60000, windowsHide: true } + ); + assert.strictEqual(proc.status, 0, `client must finish: ${proc.stdout} ${proc.stderr}`); + const report = JSON.parse(proc.stdout); + const first = report.firstWrite.structuredContent; + assert.deepStrictEqual(first, + { ok: true, committed: true, stateRev: report.firstOpen.stateRev + 1, replayed: false, key: 'objective' }); + assert.notStrictEqual(report.reopen.workspaceHandle, report.firstOpen.workspaceHandle, + 'the second server minted a different handle'); + assert.strictEqual(report.reopen.stateGen, report.firstOpen.stateGen, + 'same store lineage across the restart'); + assert.deepStrictEqual(report.retry.structuredContent, { ...first, replayed: true }, + 'the verbatim retry through the NEW handle replays the recorded outcome'); + const disk = readState(repo); + assert.strictEqual(disk.rev, report.firstOpen.stateRev + 1, 'one commit total'); + assert.strictEqual(disk.history.length, 1, 'one application total'); +}); + +// --------------------------------------------------------------------------- +// Step 4.2: the session verbs — state.append, open_loop.close/park, +// assumption.close, compile.done. The mechanism is proven on the canary above; +// these prove each verb's meaning is the CLI's meaning, its gates travel as +// schema, and its refusals stay byte-pure. +// --------------------------------------------------------------------------- + +ok('V1 the --write roster advertises all ten write tools with pinned contracts', () => { + const repo = initRepo('v1-repo'); + const conn = service([repo], true).createConnection(); + const tools = modern(conn, 'tools/list', {}).result.tools; + assert.deepStrictEqual(tools.map((t) => t.name), WRITE_ROSTER); + const byName = new Map(tools.map((t) => [t.name, t])); + // Only the additive writes are non-destructive; a status transition or an + // overwrite is not additive merely because provenance survives. + for (const [name, destructive] of [ + ['state.append', false], ['open_loop.close', true], ['open_loop.park', true], + ['assumption.close', true], ['compile.done', true], + ['artifact.add', true], ['artifact.close', true], ['artifact.retract', true], + ['score.aperture', false], + ]) { + const tool = byName.get(name); + assert.deepStrictEqual(tool.annotations, + { readOnlyHint: false, destructiveHint: destructive, idempotentHint: true, openWorldHint: false }, name); + assert.strictEqual(tool.inputSchema.additionalProperties, false, name); + assert.strictEqual(tool.outputSchema.oneOf.length, 2, `${name} has success and error branches`); + assert.deepStrictEqual(tool.outputSchema.oneOf[1].properties.error.enum, [ + 'StateNotInitialized', 'StaleGeneration', 'StaleStateRev', + 'OperationIdConflict', 'DeterministicIdConflict', 'UnknownRecordId', + 'ArtifactClosed', 'ClosureBlocked', 'HumanAuthorityRequired', 'RetractRefused', + 'WriteFailed', + ], name); + } + const required = (name) => byName.get(name).inputSchema.required; + assert.deepStrictEqual(required('state.append'), [...ENVELOPE_KEYS, 'collection', 'item']); + assert.deepStrictEqual(required('open_loop.close'), [...ENVELOPE_KEYS, 'id', 'evidence']); + assert.deepStrictEqual(required('open_loop.park'), [...ENVELOPE_KEYS, 'id', 'owner', 'revisitTrigger']); + assert.deepStrictEqual(required('assumption.close'), [...ENVELOPE_KEYS, 'id', 'outcome', 'evidence']); + assert.deepStrictEqual(required('compile.done'), [...ENVELOPE_KEYS]); + assert.deepStrictEqual(required('artifact.add'), [...ENVELOPE_KEYS, 'item']); + assert.deepStrictEqual(required('artifact.close'), [...ENVELOPE_KEYS, 'id']); + // supersededBy is the one optional wire field: allowed by the schema, never required. + assert.deepStrictEqual(required('artifact.retract'), [...ENVELOPE_KEYS, 'id', 'reason']); + assert.ok(byName.get('artifact.retract').inputSchema.properties.supersededBy); + assert.deepStrictEqual(required('score.aperture'), [...ENVELOPE_KEYS, ...APERTURE_DIMS]); + const success = (name) => byName.get(name).outputSchema.oneOf[0].required; + const COMMON = ['ok', 'committed', 'stateRev', 'replayed']; + assert.deepStrictEqual(success('state.append'), [...COMMON, 'collection', 'recordId', 'deduped']); + assert.deepStrictEqual(success('open_loop.close'), [...COMMON, 'openLoopId', 'status']); + assert.deepStrictEqual(success('open_loop.park'), [...COMMON, 'openLoopId', 'status']); + assert.deepStrictEqual(success('assumption.close'), [...COMMON, 'assumptionId', 'status']); + assert.deepStrictEqual(success('compile.done'), [...COMMON, 'checkpointed', 'lastCompileAt']); + assert.deepStrictEqual(success('artifact.add'), [...COMMON, 'artifactId', 'artifactRev', 'action']); + assert.deepStrictEqual(success('artifact.close'), [...COMMON, 'artifactId', 'artifactRev', 'status']); + assert.deepStrictEqual(success('artifact.retract'), [...COMMON, 'artifactId', 'status', 'supersededBy']); + assert.deepStrictEqual(success('score.aperture'), [...COMMON, + 'score', 'level', 'name', 'implement', 'sequence', 'mapRequired', 'dimensions', 'scope', 'recordedFog']); + // The gated constructors are not appendable — the enum itself says so. + assert.deepStrictEqual(byName.get('state.append').inputSchema.properties.collection.enum, + ['decisions', 'assumptions', 'openLoops', 'touchedFiles', 'history']); +}); + +ok('V2 no session or artifact verb is listed or dispatchable on a flagless server', () => { + const repo = initRepo('v2-repo'); + const conn = service([repo], false).createConnection(); + const listed = modern(conn, 'tools/list', {}).result.tools.map((t) => t.name); + const open = openWorkspace(conn, repo); + for (const tool of [...SESSION_VERBS, ...ARTIFACT_VERBS]) { + assert.ok(!listed.includes(tool), `${tool} must not be advertised`); + const response = callTool(conn, 'modern', tool, envelopeFor(open, {})); + assert.strictEqual(response.error && response.error.code, -32602, tool); + assert.match(response.error.message, /unknown tool/); + } +}); + +ok('V3 state.append commits one record with a derived id; the CLI writes the same record', () => { + const mcpRepo = initRepo('v3-mcp'); + const cliRepo = initRepo('v3-cli'); + const conn = service([mcpRepo], true).createConnection(); + const open = openWorkspace(conn, mcpRepo); + const result = payload(callTool(conn, 'modern', 'state.append', + envelopeFor(open, { collection: 'decisions', item: { text: 'ship 4.2' } }))); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.committed, true); + assert.strictEqual(result.stateRev, open.stateRev + 1); + assert.strictEqual(result.collection, 'decisions'); + assert.strictEqual(result.deduped, false); + assert.match(result.recordId, /^dec-[0-9a-f]{32}$/, 'MCP-minted record ids are derived'); + childProcess.execFileSync(process.execPath, + [RATCHET, 'state', 'append', 'decisions', '{"text":"ship 4.2"}'], + { cwd: cliRepo, encoding: 'utf8', env: cleanGitEnv(), windowsHide: true }); + const viaMcp = readState(mcpRepo); + const viaCli = readState(cliRepo); + assert.strictEqual(viaMcp.decisions[0].id, result.recordId); + assert.strictEqual(viaMcp.decisions[0].text, viaCli.decisions[0].text); + assert.strictEqual(viaMcp.dirty, true); + assert.strictEqual(viaCli.dirty, true); + assert.match(viaCli.decisions[0].id, /^dec-[0-9a-z]+-[0-9a-f]+$/, 'the CLI keeps random ids'); + assert.strictEqual(viaMcp.operations.length, 1, 'the receipt rode the same commit'); +}); + +ok('V4 a claimed non-birth status never crosses the boundary; an unclaimed record births open', () => { + const repo = initRepo('v4-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + const before = storeSnapshot(repo); + const refused = boundaryRefusal(callTool(conn, 'modern', 'state.append', + envelopeFor(open, { collection: 'openLoops', item: { text: 'x', status: 'closed' } }))); + assert.match(refused.message, /born "open"/); + boundaryRefusal(callTool(conn, 'modern', 'state.append', + envelopeFor(open, { collection: 'assumptions', item: { text: 'x', status: 'tested' } }))); + assert.deepStrictEqual(storeSnapshot(repo), before, 'no claimed status moved a byte'); + const result = payload(callTool(conn, 'modern', 'state.append', + envelopeFor(open, { collection: 'openLoops', item: { text: 'a loop' } }))); + const disk = readState(repo).openLoops[0]; + assert.strictEqual(disk.status, 'open', 'birth status is forced, not accepted'); + assert.strictEqual(disk.id, result.recordId); +}); + +ok('V5 a same-text loop dedups as a no-op naming the existing record', () => { + const repo = initRepo('v5-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + const first = payload(callTool(conn, 'modern', 'state.append', + envelopeFor(open, { collection: 'openLoops', item: { text: 'The Same Loop' } }))); + const before = storeSnapshot(repo); + const dup = payload(callTool(conn, 'modern', 'state.append', envelopeFor( + { ...open, stateRev: open.stateRev + 1 }, + { collection: 'openLoops', item: { text: ' the same loop ' } } + ))); + assert.deepStrictEqual(dup, { + ok: true, committed: false, stateRev: open.stateRev + 1, replayed: false, + collection: 'openLoops', recordId: first.recordId, deduped: true, + }); + assert.deepStrictEqual(storeSnapshot(repo), before, + 'a dedup is a no-op — no revision, no receipt, no second record'); +}); + +ok('V6 open_loop.close transitions with evidence; one meaning on both boundaries', () => { + const mcpRepo = initRepo('v6-mcp'); + const cliRepo = initRepo('v6-cli'); + const conn = service([mcpRepo], true).createConnection(); + const open = openWorkspace(conn, mcpRepo); + payload(callTool(conn, 'modern', 'state.append', + envelopeFor(open, { collection: 'openLoops', item: { id: 'loop-fixed', text: 'close me' } }))); + const result = payload(callTool(conn, 'modern', 'open_loop.close', envelopeFor( + { ...open, stateRev: open.stateRev + 1 }, + { id: 'loop-fixed', evidence: 'the test passed' } + ))); + assert.deepStrictEqual(result, { + ok: true, committed: true, stateRev: open.stateRev + 2, replayed: false, + openLoopId: 'loop-fixed', status: 'closed', + }); + const cli = (args) => childProcess.execFileSync(process.execPath, [RATCHET, ...args], + { cwd: cliRepo, encoding: 'utf8', env: cleanGitEnv(), windowsHide: true }); + cli(['state', 'append', 'openLoops', '{"id":"loop-fixed","text":"close me"}']); + cli(['state', 'close', 'openLoops', 'loop-fixed', '--evidence', 'the test passed']); + const viaMcp = readState(mcpRepo); + const viaCli = readState(cliRepo); + const strip = (r) => ({ id: r.id, text: r.text, status: r.status, evidence: r.evidence }); + assert.deepStrictEqual(strip(viaMcp.openLoops[0]), strip(viaCli.openLoops[0])); + assert.ok(viaMcp.openLoops[0].closedAt, 'the transition is stamped'); + const event = (h) => ({ event: h.event, note: h.note }); + assert.deepStrictEqual(viaMcp.history.map(event), viaCli.history.map(event), + 'one verb meaning on both boundaries'); + assert.match(viaMcp.history[0].id, /^hist-[0-9a-f]{32}$/); +}); + +ok('V7 open_loop.park assigns an owner and a revisit trigger — attribution, not a waiver', () => { + const repo = initRepo('v7-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + payload(callTool(conn, 'modern', 'state.append', + envelopeFor(open, { collection: 'openLoops', item: { id: 'loop-parkme', text: 'park me' } }))); + const result = payload(callTool(conn, 'modern', 'open_loop.park', envelopeFor( + { ...open, stateRev: open.stateRev + 1 }, + { id: 'loop-parkme', owner: 'Danny', revisitTrigger: 'when 4.3 lands' } + ))); + assert.deepStrictEqual(result, { + ok: true, committed: true, stateRev: open.stateRev + 2, replayed: false, + openLoopId: 'loop-parkme', status: 'parked', + }); + const disk = readState(repo).openLoops[0]; + assert.strictEqual(disk.status, 'parked'); + assert.strictEqual(disk.owner, 'Danny'); + assert.strictEqual(disk.revisitTrigger, 'when 4.3 lands'); + assert.strictEqual(disk.evidence, undefined, 'a park closes nothing and proves nothing'); + assert.strictEqual(readState(repo).history[0].event, 'loop.parked'); +}); + +ok('V7b open_loop.park means the same thing on both boundaries', () => { + const mcpRepo = initRepo('v7b-mcp'); + const cliRepo = initRepo('v7b-cli'); + const conn = service([mcpRepo], true).createConnection(); + const open = openWorkspace(conn, mcpRepo); + payload(callTool(conn, 'modern', 'state.append', + envelopeFor(open, { collection: 'openLoops', item: { id: 'loop-parkme', text: 'park me' } }))); + payload(callTool(conn, 'modern', 'open_loop.park', envelopeFor( + { ...open, stateRev: open.stateRev + 1 }, + { id: 'loop-parkme', owner: 'Danny', revisitTrigger: 'when 4.3 lands' } + ))); + const cli = (args) => childProcess.execFileSync(process.execPath, [RATCHET, ...args], + { cwd: cliRepo, encoding: 'utf8', env: cleanGitEnv(), windowsHide: true }); + cli(['state', 'append', 'openLoops', '{"id":"loop-parkme","text":"park me"}']); + cli(['state', 'close', 'openLoops', 'loop-parkme', + '--park', '--owner', 'Danny', '--revisit-trigger', 'when 4.3 lands']); + const viaMcp = readState(mcpRepo); + const viaCli = readState(cliRepo); + const strip = (r) => ({ + id: r.id, text: r.text, status: r.status, + owner: r.owner, revisitTrigger: r.revisitTrigger, evidence: r.evidence, + }); + assert.deepStrictEqual(strip(viaMcp.openLoops[0]), strip(viaCli.openLoops[0])); + const event = (h) => ({ event: h.event, note: h.note }); + assert.deepStrictEqual(viaMcp.history.map(event), viaCli.history.map(event), + 'one park meaning on both boundaries'); +}); + +ok('V8 assumption.close ends an assumption proven or dead, never otherwise', () => { + const repo = initRepo('v8-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + payload(callTool(conn, 'modern', 'state.append', + envelopeFor(open, { collection: 'assumptions', item: { id: 'asm-fixed', text: 'it holds' } }))); + const before = storeSnapshot(repo); + boundaryRefusal(callTool(conn, 'modern', 'assumption.close', envelopeFor( + { ...open, stateRev: open.stateRev + 1 }, + { id: 'asm-fixed', outcome: 'maybe', evidence: 'wishful' } + ))); + assert.deepStrictEqual(storeSnapshot(repo), before, 'a third outcome never crosses'); + const result = payload(callTool(conn, 'modern', 'assumption.close', envelopeFor( + { ...open, stateRev: open.stateRev + 1 }, + { id: 'asm-fixed', outcome: 'killed', evidence: 'contradicted by V8' } + ))); + assert.deepStrictEqual(result, { + ok: true, committed: true, stateRev: open.stateRev + 2, replayed: false, + assumptionId: 'asm-fixed', status: 'killed', + }); + const disk = readState(repo).assumptions[0]; + assert.strictEqual(disk.status, 'killed'); + assert.strictEqual(disk.evidence, 'contradicted by V8'); + assert.strictEqual(readState(repo).history[0].event, 'assumption.killed'); +}); + +ok('V8b assumption.close means the same thing on both boundaries', () => { + const mcpRepo = initRepo('v8b-mcp'); + const cliRepo = initRepo('v8b-cli'); + const conn = service([mcpRepo], true).createConnection(); + const open = openWorkspace(conn, mcpRepo); + payload(callTool(conn, 'modern', 'state.append', + envelopeFor(open, { collection: 'assumptions', item: { id: 'asm-fixed', text: 'it holds' } }))); + payload(callTool(conn, 'modern', 'assumption.close', envelopeFor( + { ...open, stateRev: open.stateRev + 1 }, + { id: 'asm-fixed', outcome: 'killed', evidence: 'contradicted by V8b' } + ))); + const cli = (args) => childProcess.execFileSync(process.execPath, [RATCHET, ...args], + { cwd: cliRepo, encoding: 'utf8', env: cleanGitEnv(), windowsHide: true }); + cli(['state', 'append', 'assumptions', '{"id":"asm-fixed","text":"it holds"}']); + cli(['state', 'close', 'assumptions', 'asm-fixed', + '--outcome', 'killed', '--evidence', 'contradicted by V8b']); + const viaMcp = readState(mcpRepo); + const viaCli = readState(cliRepo); + const strip = (r) => ({ id: r.id, text: r.text, status: r.status, evidence: r.evidence }); + assert.deepStrictEqual(strip(viaMcp.assumptions[0]), strip(viaCli.assumptions[0])); + const event = (h) => ({ event: h.event, note: h.note }); + assert.deepStrictEqual(viaMcp.history.map(event), viaCli.history.map(event), + 'one kill meaning on both boundaries'); +}); + +ok('V9 a transition on a record that does not exist refuses UnknownRecordId with zero bytes', () => { + const repo = initRepo('v9-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + const before = storeSnapshot(repo); + for (const [tool, semantic] of [ + ['open_loop.close', { id: 'loop-ghost', evidence: 'e' }], + ['open_loop.park', { id: 'loop-ghost', owner: 'o', revisitTrigger: 't' }], + ['assumption.close', { id: 'asm-ghost', outcome: 'tested', evidence: 'e' }], + ['artifact.close', { id: 'art-ghost' }], + ['artifact.retract', { id: 'art-ghost', reason: 'gone' }], + ]) { + const structured = refusal(callTool(conn, 'modern', tool, envelopeFor(open, semantic))); + assert.strictEqual(structured.error, 'UnknownRecordId', tool); + } + assert.deepStrictEqual(storeSnapshot(repo), before, 'no ghost transition moved a byte'); +}); + +ok('V10 compile.done clears dirty in one move; the verbatim retry replays the same stamp', () => { + const mcpRepo = initRepo('v10-mcp'); + const cliRepo = initRepo('v10-cli'); + const conn = service([mcpRepo], true).createConnection(); + const open = openWorkspace(conn, mcpRepo); + payload(callTool(conn, 'modern', 'state.set', + envelopeFor(open, { key: 'objective', value: 'checkpoint me' }))); + assert.strictEqual(readState(mcpRepo).dirty, true); + const envelope = envelopeFor({ ...open, stateRev: open.stateRev + 1 }, {}); + const result = payload(callTool(conn, 'modern', 'compile.done', envelope)); + assert.strictEqual(result.committed, true); + assert.strictEqual(result.stateRev, open.stateRev + 2); + assert.strictEqual(result.checkpointed, true); + const disk = readState(mcpRepo); + assert.strictEqual(disk.dirty, false, 'the checkpoint clears dirty'); + assert.strictEqual(disk.lastCompileAt, result.lastCompileAt); + const before = storeSnapshot(mcpRepo); + const retry = payload(callTool(conn, 'modern', 'compile.done', envelope)); + assert.deepStrictEqual(retry, { ...result, replayed: true }, + 'the retry is the recorded stamp, not a new one'); + assert.deepStrictEqual(storeSnapshot(mcpRepo), before); + const cli = (args) => childProcess.execFileSync(process.execPath, [RATCHET, ...args], + { cwd: cliRepo, encoding: 'utf8', env: cleanGitEnv(), windowsHide: true }); + cli(['state', 'set', 'objective', 'checkpoint me']); + cli(['compile', 'done']); + const viaCli = readState(cliRepo); + assert.strictEqual(viaCli.dirty, false); + const event = (h) => ({ event: h.event, note: h.note }); + assert.deepStrictEqual(disk.history.map(event), viaCli.history.map(event), + 'one checkpoint meaning on both boundaries'); +}); + +ok('V11 malformed verb arguments refuse at the boundary with zero bytes moved', () => { + const repo = initRepo('v11-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + const before = storeSnapshot(repo); + const cases = [ + ['state.append', { collection: 'artifacts', item: {} }], + ['state.append', { collection: 'defects', item: {} }], + ['state.append', { collection: 'nope', item: {} }], + ['state.append', { collection: 'decisions', item: [] }], + ['state.append', { collection: 'decisions', item: 'text' }], + ['state.append', { collection: 'decisions', item: { id: '', text: 'x' } }], + ['state.append', { collection: 'decisions' }], + ['open_loop.close', { id: 'x', evidence: '' }], + ['open_loop.close', { id: 'x', evidence: ' ' }], + ['open_loop.close', { id: '', evidence: 'e' }], + ['open_loop.close', { id: 'x' }], + ['open_loop.park', { id: 'x', owner: '', revisitTrigger: 't' }], + ['open_loop.park', { id: 'x', owner: 'o', revisitTrigger: '' }], + ['assumption.close', { id: 'x', outcome: 'maybe', evidence: 'e' }], + ['assumption.close', { id: 'x', outcome: 'tested', evidence: '' }], + ['compile.done', { extra: true }], + ['artifact.add', { item: [] }], + ['artifact.add', { item: 'text' }], + ['artifact.add', { item: { title: 'x', id: '' } }], + ['artifact.add', { item: { title: 'x', status: 'closed' } }], + ['artifact.add', { item: { title: 'x', status: 'retracted' } }], + ['artifact.add', { item: { title: 'x', closedAt: 'yesterday' } }], + ['artifact.add', { item: { title: 'x', retracted: {} } }], + ['artifact.close', { id: '' }], + ['artifact.retract', { id: '', reason: 'r' }], + ['artifact.retract', { id: 'x', reason: '' }], + ['artifact.retract', { id: 'x', reason: 'r', supersededBy: '' }], + ['score.aperture', { ambiguity: 3, terrain: 0, taste: 0, blastRadius: 0, reversibility: 0 }], + ['score.aperture', { ambiguity: '1', terrain: 0, taste: 0, blastRadius: 0, reversibility: 0 }], + ['score.aperture', { terrain: 0, taste: 0, blastRadius: 0, reversibility: 0 }], + ['score.aperture', { ambiguity: 0, terrain: 0, taste: 0, blastRadius: 0, reversibility: 0, extra: 1 }], + ]; + for (const [tool, semantic] of cases) { + boundaryRefusal(callTool(conn, 'modern', tool, envelopeFor(open, semantic))); + } + assert.deepStrictEqual(storeSnapshot(repo), before, 'no malformed argument moved a byte'); + const gated = boundaryRefusal(callTool(conn, 'modern', 'state.append', + envelopeFor(open, { collection: 'artifacts', item: {} }))); + assert.match(gated.message, /artifact/, 'the refusal names the gated door'); +}); + +ok('V12 every session and artifact verb answers a foreign handle with the one non-enumerating refusal', () => { + const repo = initRepo('v12-repo'); + const server = service([repo], true); + const conn = server.createConnection(); + const open = openWorkspace(conn, repo); + const foreign = server.createConnection(); + const messages = new Set(); + for (const [tool, semantic] of [ + ['state.append', { collection: 'decisions', item: { text: 'x' } }], + ['open_loop.close', { id: 'x', evidence: 'e' }], + ['open_loop.park', { id: 'x', owner: 'o', revisitTrigger: 't' }], + ['assumption.close', { id: 'x', outcome: 'tested', evidence: 'e' }], + ['compile.done', {}], + ['artifact.add', { item: { title: 'x' } }], + ['artifact.close', { id: 'x' }], + ['artifact.retract', { id: 'x', reason: 'r' }], + ['score.aperture', { ambiguity: 0, terrain: 0, taste: 0, blastRadius: 0, reversibility: 0 }], + ]) { + messages.add(boundaryRefusal(callTool(foreign, 'modern', tool, envelopeFor(open, semantic))).message); + } + assert.strictEqual(messages.size, 1, 'one answer for every foreign handle'); +}); + +// --------------------------------------------------------------------------- +// Step 4.3: the artifact verbs + score.aperture. The lifecycle gates the CLI +// earned (bound proof, probe discipline, no waiver arguments) hold unchanged +// on the wire, and the one aperture write is serialized exactly once. +// --------------------------------------------------------------------------- + +const journal = require('../src/evolve/journal'); +const lifecycle = require('../src/lifecycle'); + +function keepFields(artifactId, over) { + return { + target: 'thing.js', + artifactId, + verdict: 'KEEP', + verification: { commands: [{ command: 'node -e 0', pass: true }], result: 'pass' }, + seam: { evidenceType: 'test', testedSeam: 'x', shipSeam: 'x', seamMatch: 'exact', independentFromBuilderMethod: true }, + ...over, + }; +} + +// An artifact with a real file and a KEEP bound to its exact revision — the +// smallest store a certified close can be earned from. +function closableArtifact(repo, conn, open, id) { + fs.writeFileSync(path.join(repo, 'thing.js'), 'shipped bytes', 'utf8'); + payload(callTool(conn, 'modern', 'artifact.add', envelopeFor( + { ...open, stateRev: readState(repo).rev }, + { item: { id, title: 'thing', kind: 'code', path: 'thing.js' } } + ))); + const art = readState(repo).artifacts.find((a) => a.id === id); + const fp = lifecycle.fingerprint(repo, art); + journal.appendEvent(repo, keepFields(id), { verifiedHash: fp.hash, verifiedRev: fp.rev }); +} + +ok('V13 artifact.add creates, revises, and no-ops on one meaning — both boundaries', () => { + const mcpRepo = initRepo('v13-mcp'); + const cliRepo = initRepo('v13-cli'); + const conn = service([mcpRepo], true).createConnection(); + const open = openWorkspace(conn, mcpRepo); + const created = payload(callTool(conn, 'modern', 'artifact.add', + envelopeFor(open, { item: { title: 'spec v0', kind: 'artifact' } }))); + assert.strictEqual(created.committed, true); + assert.strictEqual(created.action, 'created'); + assert.strictEqual(created.artifactRev, 1); + assert.match(created.artifactId, /^art-[0-9a-f]{32}$/, 'MCP-minted artifact ids are derived'); + const revised = payload(callTool(conn, 'modern', 'artifact.add', envelopeFor( + { ...open, stateRev: open.stateRev + 1 }, + { item: { id: created.artifactId, title: 'spec v1' } } + ))); + assert.deepStrictEqual(revised, { + ok: true, committed: true, stateRev: open.stateRev + 2, replayed: false, + artifactId: created.artifactId, artifactRev: 2, action: 'revised', + }); + const before = storeSnapshot(mcpRepo); + const idem = payload(callTool(conn, 'modern', 'artifact.add', envelopeFor( + { ...open, stateRev: open.stateRev + 2 }, + { item: { id: created.artifactId, title: 'spec v1' } } + ))); + assert.deepStrictEqual(idem, { + ok: true, committed: false, stateRev: open.stateRev + 2, replayed: false, + artifactId: created.artifactId, artifactRev: 2, action: 'unchanged', + }); + assert.deepStrictEqual(storeSnapshot(mcpRepo), before, + 'an identical revision moves nothing — proof bound to rev 2 survives'); + const cli = (args) => childProcess.execFileSync(process.execPath, [RATCHET, ...args], + { cwd: cliRepo, encoding: 'utf8', env: cleanGitEnv(), windowsHide: true }); + cli(['artifact', 'add', '{"title":"spec v0","kind":"artifact"}']); + cli(['artifact', 'add', `{"id":"${readState(cliRepo).artifacts[0].id}","title":"spec v1"}`]); + const strip = (a) => ({ title: a.title, kind: a.kind, status: a.status, rev: a.rev, holes: a.holes }); + assert.deepStrictEqual(strip(readState(mcpRepo).artifacts[0]), strip(readState(cliRepo).artifacts[0])); + const event = (h) => ({ event: h.event }); + assert.deepStrictEqual(readState(mcpRepo).history.map(event), readState(cliRepo).history.map(event), + 'one add-then-revise meaning on both boundaries'); +}); + +ok('V14 artifact.add refuses claimed lifecycle at the boundary and closed records in the domain', () => { + const repo = initRepo('v14-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + const before = storeSnapshot(repo); + const claimed = boundaryRefusal(callTool(conn, 'modern', 'artifact.add', + envelopeFor(open, { item: { title: 'x', status: 'closed' } }))); + assert.match(claimed.message, /terminal statuses are earned/); + const reserved = boundaryRefusal(callTool(conn, 'modern', 'artifact.add', + envelopeFor(open, { item: { title: 'x', closedAt: 'yesterday' } }))); + assert.match(reserved.message, /gated transition/); + assert.deepStrictEqual(storeSnapshot(repo), before, 'no claimed lifecycle moved a byte'); + closableArtifact(repo, conn, open, 'art-sealed-v14'); + payload(callTool(conn, 'modern', 'artifact.close', + envelopeFor({ ...open, stateRev: readState(repo).rev }, { id: 'art-sealed-v14' }))); + const sealed = storeSnapshot(repo); + const refused = refusal(callTool(conn, 'modern', 'artifact.add', + envelopeFor({ ...open, stateRev: readState(repo).rev }, { item: { id: 'art-sealed-v14', title: 'rewrite history' } }))); + assert.strictEqual(refused.error, 'ArtifactClosed'); + assert.deepStrictEqual(storeSnapshot(repo), sealed, 'closure stays a historical fact'); +}); + +ok('V15 artifact.close is earned by bound proof, replays verbatim, no-ops when certified', () => { + const repo = initRepo('v15-repo'); + const conn = service([repo], true).createConnection(); + const open = openWorkspace(conn, repo); + const rev = () => readState(repo).rev; + // No proof bound → blocked, zero bytes. + payload(callTool(conn, 'modern', 'artifact.add', + envelopeFor(open, { item: { id: 'art-unproven-v15', title: 'unproven', kind: 'code', path: 'missing.js' } }))); + let before = storeSnapshot(repo); + const blocked = refusal(callTool(conn, 'modern', 'artifact.close', + envelopeFor({ ...open, stateRev: rev() }, { id: 'art-unproven-v15' }))); + assert.strictEqual(blocked.error, 'ClosureBlocked'); + assert.deepStrictEqual(storeSnapshot(repo), before, 'a blocked closure moves zero bytes'); + // Record-scope proof, even bound, has no wire spelling — human authority. + payload(callTool(conn, 'modern', 'artifact.add', + envelopeFor({ ...open, stateRev: rev() }, { item: { id: 'art-note-v15', title: 'release note', kind: 'docs', path: 'CHANGELOG.md#unreleased' } }))); + const note = readState(repo).artifacts.find((a) => a.id === 'art-note-v15'); + const noteFp = lifecycle.fingerprint(repo, note); + assert.strictEqual(noteFp.hashScope, 'record'); + journal.appendEvent(repo, { + target: 'CHANGELOG.md', artifactId: 'art-note-v15', verdict: 'KEEP', + verification: { manualChecks: ['read end to end'], result: 'manual' }, + }, { verifiedHash: noteFp.hash, verifiedRev: noteFp.rev }); + before = storeSnapshot(repo); + const scoped = refusal(callTool(conn, 'modern', 'artifact.close', + envelopeFor({ ...open, stateRev: rev() }, { id: 'art-note-v15' }))); + assert.strictEqual(scoped.error, 'HumanAuthorityRequired'); + assert.deepStrictEqual(storeSnapshot(repo), before, 'record-scope closure moved zero bytes'); + // Earned close commits once, the verbatim retry replays, a fresh op no-ops. + closableArtifact(repo, conn, open, 'art-close-v15'); + const closeRev = rev(); + const envelope = envelopeFor({ ...open, stateRev: closeRev }, { id: 'art-close-v15' }); + const result = payload(callTool(conn, 'modern', 'artifact.close', envelope)); + assert.deepStrictEqual(result, { + ok: true, committed: true, stateRev: closeRev + 1, replayed: false, + artifactId: 'art-close-v15', artifactRev: 1, status: 'closed', + }); + const disk = readState(repo).artifacts.find((a) => a.id === 'art-close-v15'); + assert.strictEqual(disk.status, 'closed'); + assert.ok(disk.closedBy && disk.closedHash, 'the certificate names its proof'); + assert.strictEqual(readState(repo).history.at(-1).event, 'artifact.closed'); + before = storeSnapshot(repo); + const retry = payload(callTool(conn, 'modern', 'artifact.close', envelope)); + assert.deepStrictEqual(retry, { ...result, replayed: true }, 'the retry is the recorded certificate'); + const again = payload(callTool(conn, 'modern', 'artifact.close', + envelopeFor({ ...open, stateRev: rev() }, { id: 'art-close-v15' }))); + assert.deepStrictEqual(again, { + ok: true, committed: false, stateRev: closeRev + 1, replayed: false, + artifactId: 'art-close-v15', artifactRev: 1, status: 'closed', + }); + assert.deepStrictEqual(storeSnapshot(repo), before, 'a re-close of a certified artifact moves nothing'); +}); + +ok('V15b artifact.close means the same thing on both boundaries', () => { + const mcpRepo = initRepo('v15b-mcp'); + const cliRepo = initRepo('v15b-cli'); + const conn = service([mcpRepo], true).createConnection(); + const open = openWorkspace(conn, mcpRepo); + closableArtifact(mcpRepo, conn, open, 'art-both-v15b'); + payload(callTool(conn, 'modern', 'artifact.close', + envelopeFor({ ...open, stateRev: readState(mcpRepo).rev }, { id: 'art-both-v15b' }))); + const cli = (args) => childProcess.execFileSync(process.execPath, [RATCHET, ...args], + { cwd: cliRepo, encoding: 'utf8', env: cleanGitEnv(), windowsHide: true }); + fs.writeFileSync(path.join(cliRepo, 'thing.js'), 'shipped bytes', 'utf8'); + cli(['artifact', 'add', '{"id":"art-both-v15b","title":"thing","kind":"code","path":"thing.js"}']); + const art = readState(cliRepo).artifacts[0]; + const fp = lifecycle.fingerprint(cliRepo, art); + journal.appendEvent(cliRepo, keepFields('art-both-v15b'), { verifiedHash: fp.hash, verifiedRev: fp.rev }); + cli(['artifact', 'close', 'art-both-v15b']); + const strip = (a) => ({ id: a.id, status: a.status, closedRev: a.closedRev, closedHash: a.closedHash }); + assert.deepStrictEqual(strip(readState(mcpRepo).artifacts[0]), strip(readState(cliRepo).artifacts[0]), + 'same bytes, same certificate on both boundaries'); + const event = (h) => ({ event: h.event }); + assert.deepStrictEqual(readState(mcpRepo).history.map(event), readState(cliRepo).history.map(event)); +}); + +ok('V16 artifact.retract requires its reason; probe discipline holds on the wire — both boundaries', () => { + const mcpRepo = initRepo('v16-mcp'); + const cliRepo = initRepo('v16-cli'); + const conn = service([mcpRepo], true).createConnection(); + const open = openWorkspace(conn, mcpRepo); + const rev = () => readState(mcpRepo).rev; + payload(callTool(conn, 'modern', 'artifact.add', + envelopeFor(open, { item: { id: 'art-stale-v16', title: 'stale claim' } }))); + const result = payload(callTool(conn, 'modern', 'artifact.retract', + envelopeFor({ ...open, stateRev: rev() }, { id: 'art-stale-v16', reason: 'premise disproven' }))); + assert.deepStrictEqual(result, { + ok: true, committed: true, stateRev: open.stateRev + 2, replayed: false, + artifactId: 'art-stale-v16', status: 'retracted', supersededBy: null, + }); + const disk = readState(mcpRepo).artifacts[0]; + assert.strictEqual(disk.status, 'retracted'); + assert.strictEqual(disk.retracted.keptForProvenance, true, 'provenance survives the retraction'); + // Probe exits: a vague reason, an unnamed promotion, and a probe-for-probe + // promotion all refuse with zero bytes. + payload(callTool(conn, 'modern', 'artifact.add', + envelopeFor({ ...open, stateRev: rev() }, { item: { id: 'art-probe-v16', title: 'probe', kind: 'probe' } }))); + const before = storeSnapshot(mcpRepo); + for (const semantic of [ + { id: 'art-probe-v16', reason: 'just done with it' }, + { id: 'art-probe-v16', reason: 'promoted: rebuilt' }, + { id: 'art-probe-v16', reason: 'promoted: rebuilt', supersededBy: 'art-nobody' }, + ]) { + const refused = refusal(callTool(conn, 'modern', 'artifact.retract', + envelopeFor({ ...open, stateRev: rev() }, semantic))); + assert.strictEqual(refused.error, 'RetractRefused', JSON.stringify(semantic)); + } + assert.deepStrictEqual(storeSnapshot(mcpRepo), before, 'no undisciplined probe exit moved a byte'); + const promoted = payload(callTool(conn, 'modern', 'artifact.retract', envelopeFor( + { ...open, stateRev: rev() }, + { id: 'art-probe-v16', reason: 'promoted: rebuilt for keep', supersededBy: 'art-stale-v16' } + ))); + assert.strictEqual(promoted.supersededBy, 'art-stale-v16'); + const cli = (args) => childProcess.execFileSync(process.execPath, [RATCHET, ...args], + { cwd: cliRepo, encoding: 'utf8', env: cleanGitEnv(), windowsHide: true }); + cli(['artifact', 'add', '{"id":"art-stale-v16","title":"stale claim"}']); + cli(['retract', 'art-stale-v16', '--reason', 'premise disproven']); + const strip = (a) => ({ + id: a.id, title: a.title, status: a.status, + retracted: { reason: a.retracted.reason, supersededBy: a.retracted.supersededBy, keptForProvenance: a.retracted.keptForProvenance }, + }); + assert.deepStrictEqual(strip(readState(mcpRepo).artifacts[0]), strip(readState(cliRepo).artifacts[0]), + 'one retraction meaning on both boundaries'); +}); + +ok('V17 score.aperture is byte-pure until fog is owed, serializes it once, and refuses stale', () => { + const mcpRepo = initRepo('v17-mcp'); + const cliRepo = initRepo('v17-cli'); + const conn = service([mcpRepo], true).createConnection(); + const open = openWorkspace(conn, mcpRepo); + const rev = () => readState(mcpRepo).rev; + const calm = { ambiguity: 0, terrain: 0, taste: 0, blastRadius: 0, reversibility: 0 }; + const foggy = { ambiguity: 1, terrain: 1, taste: 2, blastRadius: 1, reversibility: 1 }; + const before = storeSnapshot(mcpRepo); + const low = payload(callTool(conn, 'modern', 'score.aperture', envelopeFor(open, calm))); + assert.strictEqual(low.committed, false); + assert.strictEqual(low.recordedFog, false); + assert.strictEqual(low.level, 'A0'); + assert.strictEqual(low.mapRequired, false); + assert.ok(low.scope, 'the score names its scope on the wire'); + assert.deepStrictEqual(storeSnapshot(mcpRepo), before, 'a score that owes no fog is byte-pure'); + const high = payload(callTool(conn, 'modern', 'score.aperture', envelopeFor(open, foggy))); + assert.strictEqual(high.committed, true); + assert.strictEqual(high.recordedFog, true); + assert.strictEqual(high.mapRequired, true, 'taste 2 forces the map whatever the band'); + const loop = readState(mcpRepo).openLoops[0]; + assert.match(loop.id, /^loop-[0-9a-f]{32}$/, 'the fog loop id is derived'); + assert.strictEqual(readState(mcpRepo).history[0].event, 'fog.recorded'); + // Fog already on the record: the second score writes nothing. + const again = payload(callTool(conn, 'modern', 'score.aperture', + envelopeFor({ ...open, stateRev: rev() }, foggy))); + assert.strictEqual(again.committed, false); + assert.strictEqual(again.recordedFog, false, 'one uncertainty, one drain'); + // Aperture holds CAS like every write — no idempotence exemption. + const stale = refusal(callTool(conn, 'modern', 'score.aperture', envelopeFor(open, foggy))); + assert.strictEqual(stale.error, 'StaleStateRev'); + const cli = (args) => childProcess.execFileSync(process.execPath, [RATCHET, ...args], + { cwd: cliRepo, encoding: 'utf8', env: cleanGitEnv(), windowsHide: true }); + const viaCli = JSON.parse(cli(['score', 'aperture', JSON.stringify(foggy), '--json'])); + const projection = (r) => ({ + score: r.score, level: r.level, name: r.name, implement: r.implement, sequence: r.sequence, + mapRequired: r.mapRequired, dimensions: r.dimensions, scope: r.scope, recordedFog: r.recordedFog, + }); + assert.deepStrictEqual(projection(high), projection(viaCli), 'one aperture meaning on both boundaries'); + const stripLoop = (l) => ({ text: l.text, status: l.status }); + assert.deepStrictEqual(stripLoop(readState(mcpRepo).openLoops[0]), stripLoop(readState(cliRepo).openLoops[0]), + 'the serialized fog is the same loop on both boundaries'); +}); + +// --------------------------------------------------------------------------- + +process.stdout.write(`\n${passed} passed, ${failures.length} failed\n`); +if (failures.length) { + process.exitCode = 1; +}