evidence/MetadataGate.md
Rama master · réplica de lectura del repositorio original.
# MetadataGate — `/v1/responses` metadata echo/persist + incremental gate baseline check
Branch `fix/responses-metadata-and-gate` from `451250a`, worktree
`…/windsurfapi-autonomous-20261022/w109-metadata-gate`.
Status: implementation complete; acceptance commands run and quoted below. The
`npm run gate` run is quoted in "Gate" with its own log at
`evidence/MetadataGate/gate-incremental.log`.
---
## Item 1 — `/v1/responses` metadata (DataAuditTools F4)
### Defect
`responsesToChat` never read `metadata`, and `chatToResponse` / the store had no
slot for it. Measured at `451250a`:
```
$ grep -rn "metadata" src/handlers/responses.js | head
(no output)
$ grep -rn "metadata" src/response-store.js | head
(no output)
```
So a client that tags a request (`metadata: {trace_id, tenant}`) got a 200 with
the tag silently dropped, and a later `GET /v1/responses/{id}` could not report
it either.
### Change
- `src/handlers/responses.js`
- `normalizeResponseMetadata()` (exported): validates the OpenAI contract —
flat map, ≤16 pairs, keys ≤64 chars, string values ≤512 chars — and throws a
`400 invalid_request_error` with `param: 'metadata'` on violation. `null` and
`{}` mean "no metadata" (so POST and GET agree). Values are never logged.
- `responsesToChat` carries the validated copy as `__responseMetadata`;
`handleResponses` strips it before the upstream call (it is not a chat
parameter) and uses it for the echo and the store commit.
- `chatToResponse(..., metadata)` and `ResponsesStreamTranslator` echo it on the
response object — non-stream body and every streamed `response.*` frame —
only when the caller sent it.
- `storedResponseBody` echoes `entry.metadata` on retrieval.
- `src/response-store.js`
- New bounded `metadata` slot: `boundMetadata()` keeps ≤16 own string pairs,
≤64-char keys, ≤512-char values, drops anything else (defence in depth — the
HTTP boundary already rejects), skips `__proto__` (the generic copy-on-read
walk would rewrite the prototype).
- `metadataBytes()` is added to the entry's `bytes`, so the slot is charged to
the tenant byte share and the global budget like every other retained byte.
- `getResponse()` returns a copy (`copyStoredValue`) or `null`.
### Tests (written first, red before the implementation)
Red run, before the implementation (the store tests reported `metadata` as
`undefined` where the test expects `null`, and the byte test reported
`metadata must be metered: 74 vs 74`; the bounded-slot test threw on
`Object.entries(undefined)`):
```
$ node --import ./test/setup-env.mjs --test test/response-store-metadata.test.js
+ actual - expected
+ undefined
- null
✖ counts metadata bytes against the store budget
AssertionError: metadata must be metered: 74 vs 74
✖ bounds the whole slot by bytes, not only by pair count
TypeError: Cannot convert undefined or null to object
$ node --import ./test/setup-env.mjs --test --test-name-pattern="metadata" test/responses.test.js
ℹ tests 12 ℹ pass 7 ℹ fail 5
✖ echoes request metadata on the response object
✖ echoes metadata on the streaming response object
✖ preserves metadata through previous_response_id and GET /v1/responses/{id}
✖ rejects metadata that violates the OpenAI contract with a 400 naming the field
✖ accepts the contract maximum and echoes it unchanged
```
Green, acceptance command:
```
$ node --import ./test/setup-env.mjs --test test/responses.test.js test/response-store*.test.js
ℹ tests 207 ℹ suites 41 ℹ pass 207 ℹ fail 0 ℹ skipped 0
```
Coverage: echo (non-stream + streaming), absence stays absent, no leak into the
upstream chat body, persistence across `previous_response_id` **and** the later
`GET`, `{}`/`null` treated as absent, contract-maximum accepted, six contract
violations rejected with `param: 'metadata'`, store round-trip / refresh / copy /
foreign-caller isolation / key-count cap / key-length cap / value-length cap /
non-string drop / byte metering / derived byte bound.
### Mutation pins re-measured (test count moved +8)
```
$ node scripts/spec-baseline-check.mjs response-id-entropy-guard.json responses-post-tool-text-item.json responses-stream-done-sentinel.json usage-unknown-not-zero.json
ok response-id-entropy-guard.json: 68 pass + 0 approved skips = 68 [MEASURED_NO_SKIPS]
ok responses-post-tool-text-item.json: 83 pass + 0 approved skips = 83 [MEASURED_NO_SKIPS]
ok responses-stream-done-sentinel.json: 70 pass + 0 approved skips = 70 [MEASURED_NO_SKIPS]
ok usage-unknown-not-zero.json: 73 pass + 0 approved skips = 73 [MEASURED_NO_SKIPS]
4 spec(s) measured, 0 drift, 0 untrustworthy
```
Pins moved by measurement, not arithmetic: 60→68, 75→83, 62→70, 65→73 — each
exactly +8, the eight `test/responses.test.js` metadata tests added by this
change.
---
## Item 2 — the gate's baseline check measures, scoped and full
### What was wrong at `451250a`
`scripts/spec-baseline-check.mjs` already executed each spec's test list and
compared `pass + approved skips` with `expectBaselinePass`, so the measurement
existed. The remaining defects were scope and wording:
1. **Scope.** `selectSpecs` widened to EVERY spec whenever any `src/`, `scripts/`
or test-helper file changed — i.e. on nearly every branch. That is the full-set
cost (~100s) paid on the fast path, and it is still not the selection that sees
pin drift: a pin drifts when its own covered tests move. The release side had
no full mode wired at all (`--all` existed; nothing invoked it).
2. **Wording.** Drift printed a bespoke line instead of the sentence the mutation
harness refuses a run with, so the two readers of the same fact disagreed.
### Change
- `scripts/spec-baseline-check.mjs`
- Incremental default: measure exactly the specs whose spec JSON or `tests`
files are in the diff (`git diff base...HEAD`, `HEAD`, untracked). A
synchronized checkout (no diff) still measures everything.
- `--all`: every spec — the release scope.
- Drift now prints the harness sentence:
`baseline pass=N but spec expects M. Either the suite changed or the spec is stale — both are worth knowing before trusting any verdict below.`
(the approved-skip breakdown is appended only when this platform skipped
something).
- `scripts/local-gate.mjs`
- `gateSteps({release})` (exported): incremental passes no flag, release passes
`--all`; `parseGateArgs` accepts `--release`/`--all` and fails closed on
anything else; the summary names the scope (`INCREMENTAL GATE` /
`RELEASE GATE`).
- `package.json`: `"gate:release": "node scripts/local-gate.mjs --release"`.
- `.github/workflows/release.yml`: a release step
`node scripts/spec-baseline-check.mjs --all` before `npm run test:release` — a
tag checkout has no diff to scope by, so the release gate is the full one.
### Tests
`test/local-gate.test.js` (new): step wiring for both scopes, CLI parsing,
release-workflow wiring, `gate:release` wiring, and three subprocess runs of the
REAL gate against a disposable fixture:
- incremental passes no `--all`, release passes `--all` (recorded argv);
- a pin drifted 1→2 makes the gate `FAIL spec-baseline-check exit=1` with
`baseline pass=1 but spec expects 2. Either the suite changed or the spec is
stale…`, and **restoring the pin passes again** (not a one-way latch);
- the release scope measures a spec the diff never touched.
```
$ node --import ./test/setup-env.mjs --test test/mutate-verify-harness.test.js test/local-gate*.test.js
ℹ tests 34 ℹ suites 8 ℹ pass 7 ℹ fail 0 ℹ skipped 27
```
The 27 skips are `test/mutate-verify-harness.test.js`'s own declared platform
gate on this host (it needs the POSIX git fixture; it is in the repo's
`GATE_INERT_SKIP_PATHS` declaration for Windows), not a silent pass — `fail 0`.
### Proof against the real repository (not only a fixture)
A real pin was drifted in this worktree and the real change-scoped check was run,
then restored:
```
$ # responses-post-tool-text-item.json 82 -> 83
$ node scripts/spec-baseline-check.mjs
ok response-id-entropy-guard.json: 67 pass + 0 approved skips = 67 [MEASURED_NO_SKIPS]
DRIFT responses-post-tool-text-item.json: baseline pass=82 but spec expects 83. Either the suite changed or the spec is stale — both are worth knowing before trusting any verdict below.
ok responses-stream-done-sentinel.json: 69 pass + 0 approved skips = 69 [MEASURED_NO_SKIPS]
ok usage-unknown-not-zero.json: 72 pass + 0 approved skips = 72 [MEASURED_NO_SKIPS]
4 spec(s) measured, 1 drift, 0 untrustworthy
DRIFTED_EXIT=1
$ git checkout -- test/mutations/responses-post-tool-text-item.json # restore
$ node scripts/spec-baseline-check.mjs
4 spec(s) measured, 0 drift, 0 untrustworthy # SCOPED_EXIT=0
```
(The numbers in that block are the pins as they stood at that moment — before the
eighth metadata test was added and re-measured; the final pins are the ones quoted
in Item 1.)
### Suite regression the first full gate run caught (and the repair)
The first full `npm run gate` on this branch reported 4 failures, all in
`test/s4-r6.test.js`:
```
✖ R6 two already-read histories and subsequent reads have independent object graphs
ReferenceError: metadataBytes is not defined
```
That file does not import the store — it extracts `putResponse` / `getResponse`
by TEXT and evaluates them with an injected dependency object, so a new internal
call is an undefined name until the harness is told about it. The repair injects
the three metadata caps as deps and extracts `boundMetadata` / `metadataBytes`
exactly like the existing `copyStoredValue` case. Re-run:
```
$ node --import ./test/setup-env.mjs --test test/s4-r6.test.js
ℹ tests 4 ℹ pass 4 ℹ fail 0
```
(`test/s4-r6.test.js` is unchanged in COUNT, so `s4-r6.json`'s pin of 4 stands.)
---
## Gate
`npm run gate` on this branch (full log at
`evidence/MetadataGate/gate-incremental.log`; `*.log` is gitignored, so that file
is worktree-local — the summary below is the record):
```
$ GATE_INERT_SKIP_PATHS="test/wire-byte-identity.test.js;test/devin-connect-relogin-live.test.js;test/mutate-verify-harness.test.js;test/self-update-untracked-safety.test.js;test/detach-soak.test.js;test/detach-ls-pool-pressure.test.js" npm run gate
=== spec-static-check ===
=== spec-baseline-check ===
5 spec(s) measured, 0 drift, 0 untrustworthy
=== secret-scan ===
=== test:release ===
=== git diff --check ===
=== git diff --cached --check ===
=== Local gate summary ===
PASS spec-static-check exit=0
PASS spec-baseline-check exit=0
PASS secret-scan exit=0
PASS test:release exit=0 — 6643 pass / 0 fail / 97 skip (549 files) — 6 inert file(s), 6 declared
PASS git diff --check exit=0
PASS git diff --cached --check exit=0
SKIP mutation EXECUTION — this gate checks spec shape, anchors and baseline values, but does not run the mutants themselves; the Linux full-gate recipe runs the sweep.
INCREMENTAL GATE: PASS exit=0
```
Two things in that output are the change working:
- `spec-baseline-check` measured **5** specs (the four `test/responses.test.js`
specs plus `s4-r6`, whose test file this branch touched) with **0 drift** —
change-scoped, not the 91-spec full set the old `src/` widening would have paid
for on this diff.
- The inert census is a HOST declaration, not a repo default: the first clean
suite run (`6643 pass / 0 fail / 97 skip`) failed the gate on two undeclared
files, `test/detach-soak.test.js` (`DETACH_SOAK=1` soak) and
`test/detach-ls-pool-pressure.test.js` (`ensureLs needs to spawn a POSIX
shebang fake LS`). Both are deliberate platform/env gates; declaring them is
the documented mechanism (`GATE_INERT_SKIP_PATHS`, no default by design).
## Residual notes
- The incremental gate is now genuinely scoped, so a pin moved by a change no
spec's test list names is caught by the release gate (`--all`, wired into
`release.yml`), not by `npm run gate`. That is the trade the ticket asked for
and it is written down in both scripts.
- `npm run mutate` (the mutation harness itself) is POSIX-git-only and was not run
on this Windows host; the pins were measured with the same counting semantics
the harness uses (`pass` from structured runner events, approved skips only).
- The store's metadata caps are defence in depth: the HTTP boundary rejects a
violating map with a 400 before it can reach `putResponse`.