Skip to content

Expose the engineering record over MCP without exposing execution (E07) - #434

Merged
TheAmericanMaker merged 3 commits into
mainfrom
feat/e07-mcp
Sep 22, 2026
Merged

TheAmericanMaker merged 3 commits into
mainfrom
feat/e07-mcp

Conversation

@TheAmericanMaker

Copy link
Copy Markdown
Member

Closes part of #405.

Adds codecarto_change — the first point where the engineering record system is reachable from outside this process. It records what a host did; it never acts.

What it refuses, and why that is the feature

Refused Why
Execution No exec/spawn/target-code write/network call. Recording and doing must stay separate.
approve / accept Refused with a reason, not as an unknown action — an agent approving its own work through an ordinary tool argument is the forgery the record exists to prevent.
Self-attested proof Authority is derived from collector + attestation; provenance is overwritten, not merged. A proof claiming authority: "observed" discharges nothing.
Direct state writes State is derived from evidence, not asserted.

Deliberately not refused: a payload declaring derived fields is ignored, not rejected. Refusing would teach callers to strip the fields and change nothing about what is trusted — the claim has to be inert, not forbidden.

The gate returns needs-human-acceptance, never may-accept: this transport cannot obtain a human decision, so it says so rather than implying a capability it lacks.

An E06 defect found while testing transport

The always-stated limitation — a gate cannot show the change is correct — was missing from the early-return path. The one disclosure the module promises unconditionally was absent from exactly the outcomes a confused caller is most likely to be reading. Now a named constant applied on every path.

Verification

  • 17 unit tests + 3 real stdio round trips against the compiled server in a separate process. A surface that works in-process but not over the wire is not a surface.
  • All 9 mutations bite. Three initially survived: the refused-action list, the derived-field refusal, and — the serious one — the host capability. Flipping can_obtain_human_decision to true made the surface claim it could ask a human, and nothing failed. Same coarse-test class as E06.
  • The no-execution guard was verified to bite by injecting child_process with a real anchor (a non-existent anchor is how this project has twice proved nothing).
  • Suite 1161/1161, two consecutive runs. Build clean.
  • Smoke: exit 0, 9/9 — but only via --tarball. npm run smoke installs the published package, so it reports this tool missing until a release carries it; that caveat is now in the quickstart. The first two smoke failures were exactly this, not a packaging defect, confirmed by querying the compiled server's inventory directly.

Contract shapes, corrected rather than guessed

CHANGE_MODES has no behavior; BaselineReference takes vcs, not kind; and E01 requires a full commit hash whenever vcs is git. A create without one records vcs: "none" rather than a git baseline that names nothing. Retry-safety uses the store's existing idempotency key, not a request_id field the schema rightly refuses.

…on (E07)

Adds `codecarto_change`, the first point where the engineering record system
is reachable from outside this process. It records; it does not act.

What the surface deliberately refuses:

- Execution. No exec, no spawn, no target-code write, no network call. The
  server gained a way to record what a host did, not a second way to do
  things. A test greps the module for those symbols, and the guard was
  verified to bite by injecting `child_process` with a real anchor.
- Self-approval. `approve`/`accept` are refused with a reason rather than
  falling through as unknown actions, and `state` cannot be set directly.
- Self-attestation. A proof arriving through a tool call is `claimed`, never
  `observed`: authority is derived from collector and attestation, and the
  adapter overwrites provenance rather than merging it. A payload declaring
  `authority: "observed"` is stored faithfully and discharges nothing.

Deliberately NOT refused: a payload that declares derived fields on a proof
is ignored rather than rejected. Refusing would teach callers to strip the
fields and change nothing about what is trusted; the claim must be inert.

The gate reports `needs-human-acceptance`, never `may-accept`: an MCP tool
call cannot obtain a human decision, so it says so instead of implying a
capability the transport lacks. A mutation flipping that to `true` initially
survived — nothing asserted the host capability — which is the same coarse-
test failure class as E06.

Retry-safety uses the store's own idempotency key rather than a `request_id`
record field the schema rightly refuses. Because the store compares bytes,
the change id and timestamp are derived from the request when a key is
supplied; a fresh uuid per call would make two retries of one request differ
and be reported as a conflict.

Also fixes a real defect in E06 found while testing transport: the
always-stated limitation ("a gate cannot show the change is correct") was
missing from the early-return path, so the one disclosure the module promises
unconditionally was absent from exactly the outcomes a confused caller is
most likely to read. Now a named constant used on every path.

Tests: 17 unit tests plus 3 that drive the compiled server over real stdio
JSON-RPC in a separate process, because a surface that works in-process and
not over the wire is not a surface. All 9 mutations bite (3 initially
survived: the refused-action list, the derived-field refusal, and the host
capability above). Suite 1161/1161 on two consecutive runs.

Contract shapes corrected against the real definitions rather than guessed:
CHANGE_MODES has no "behavior", BaselineReference takes `vcs` not `kind`, and
E01 requires a full commit hash whenever vcs is `git` — so a create without
one records `vcs: "none"` rather than a git baseline naming nothing.

Refs #405
Six mutations survived a green suite. The pattern: every `update` test
asserted the REFUSAL path (a stale revision is rejected) and none asserted
the WRITE path, so an update that stored nothing at all would have passed.
Dropping the title write, dropping the outcome write, and never bumping the
revision were all invisible.

Added:
- update actually persists title and outcome, read back through a separate
  show call so a reported-but-unstored write fails
- an omitted field is left alone rather than cleared
- show reports the record's real state (seeded from a fixture whose state is
  not `draft`, since a hardcoded "draft" is indistinguishable from a working
  implementation on a freshly created change)
- whitespace-only required fields are refused
- a replay reports retried: true and a first write reports false
- a non-object proof is refused

All nine mutations now bite. Suite 1167/1167.

Refs #405
…face

Adversarial review returned REQUEST_CHANGES with nine findings. Two were real
integrity defects, reproduced personally before any fix.

F-1 (HIGH): an ACCEPTED change could be rewritten. updateChange never looked
at record.state, and `title`/`requested_outcome` are exactly the two fields an
AcceptancePresentation carries. Because validateRecord recomputes
presentation_digest from the approval's OWN embedded copy, the approval kept
validating while the change stated an outcome nobody approved. Reproduced: a
fixture forced to `accepted`, then update returned "revision 3" with the state
still accepted and the new title stored. Terminal states are now refused.

F-2 (HIGH): the compare-and-swap was opt-in. `ifRevision` was populated from
the record the adapter had just read, so it only ever compared the adapter
against itself -- last-write-wins with extra steps, and a concurrent writer's
work vanished with no error. `revision` is now required on update.

F-3: created_at was a digest of the request: a fixed 2020 epoch plus a bounded
offset, so a workspace had at most 1000 distinct creation times and anything
ordering by them got arbitrary order. Retry-safety now comes from reusing the
first call's real timestamp, recovered from the record it wrote.

F-4: ordinary bad arguments (unknown mode, non-hash baseline, oversized or
traversing request_id, idempotency conflict) surfaced as InternalError
(-32603), which hosts treat as a server bug and retry. Retrying an invalid
enum fails identically forever. Request-blaming store codes now map to
InvalidParams.

F-5: REFUSED_ACTIONS was a plain object literal, so `action: "constructor"`
printed native function source as the refusal reason. Null prototype plus an
Object.hasOwn guard.

F-8: a NUL byte and ANSI escapes reached the reported text unescaped, letting
a caller repaint how a record reads. Control characters are stripped from
displayed text; stored bytes are unchanged.

Tests, including three the review showed were proving nothing:

F-6: the laundering test sent attested_by "adapter", which E01 refuses on
SHAPE for a host-observed collector -- so with the provenance overwrite
removed it failed on validation and never reached the authority assertions. It
could not tell a working overwrite from an unrelated refusal. Now sends
host-tool-result, the value that actually launders, and asserts the STORED
provenance.

The storage-boundary disclosure ("these records could have been rewritten by
the agent whose work they describe") was missing from the gate's early-return
path -- the same defect class as the correctness limitation fixed in the
previous commit, found because a test finally asserted the specific string.
Both are now emitted by one helper used on every path.

Also added: two successive updates advance the revision by exactly one (the
`ifRevision: 1` and `+2` survivors both needed a second successful update to
observe), mode and baseline_commit are recorded as supplied, a proof is filed
against the change in the REQUEST not the payload, and InvalidParams codes are
asserted over the wire because the server's catch wrapper -- not the adapter --
is what assigns InternalError.

Kept deliberately: the prototype guards, documented as untested by
construction. The action allow-list runs after them and rejects every
inherited name anyway, so mutating either away leaves the suite green. They
are defence in depth against a reordering, not dead code -- deleting a guard
because a mutation survived is the error that let a baseline proof discharge
in E05.

Suite 1180/1180 on two consecutive runs. Every mutation bites, once the
harness was fixed to rebuild dist/ (the roundtrip tests spawn the compiled
server, so source-only mutations were invisible) and to mutate all three
asCallerError sites rather than the first.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant