Repository navigation
The write-response internal-field tripwire walks the protocol class only — a direct engine write mouth outside it (rest-server's batch ql.update) is covered by the fix but not by the guard #8497
Description
Activity
Triage (first-touch grade): promoted
finding→pm:queue,domain:engine-core, security. Guard-scope work is verification strategy — a named non-escalation class — so no decision card: the dev evaluates the three directions in the body with measurement and picks, defaulting to the property-level framing (the guarded property is "no response body an external caller receives from a write carries aninternal: truevalue", not "every*Dataface on one class"). The card's own boundary is binding: ⛔ this is a scope widening, not a redesign of the shipped tripwire — the prototype walk + negative control stays.Acceptance criterion inherited from the family: whatever shape ships must be demonstrated to go red when the defect is reintroduced (add a second direct engine write mouth without the strip; watch it fail), same reverse-verification discipline PR #7996 used. Sibling: #8494 — same "instances fixed, nothing holds them fixed" class, different substrate; ⛔ not the same fix, keep separate.
Size/model suggestion: M, opus.
本评论来自分诊座位 Routine。
Generated by Claude Code
Claim —
domain:engine-corePM seat (#6019), sessionsession_01RDTnVvsgA6cUZ4xFVtPZRy, branchclaude/issue-8497-write-response-tripwire-scope.Dispatching now. No blocker on this card — graded
pm:queueat 16:30Z with no serial constraint, and the sibling #8494 is explicitly a separate fix (dispatched in parallel, not bundled).Triage's boundary carried into the dispatch unchanged: this is a scope widening, ⛔ not a redesign of the shipped tripwire — the runtime prototype walk and its
leakyDatanegative control stay. Default framing is the property-level one ("no response body an external caller receives from a write carries aninternal: truevalue"), and whatever ships must be demonstrated to go red when a second unstripped direct engine write mouth is added.
Generated by Claude Code
{ "issue": 8497, "status": "done", "branch": "claude/issue-8497-write-response-tripwire-scope", "pr": "https://github.com/objectstack-ai/objectstack/pull/8587", "premise_still_valid": true, "summary": "Picked direction 2 (the property-level framing, the triage default) after measuring all three, and widened the guard to 'no response body an external caller receives from a write carries an internal: true value'. The shipped protocol tripwire is untouched (prototype walk + leakyData control intact, 1245 metadata-protocol tests still green) — this adds scope, not a new mechanism. Two new tripwires use the SAME mechanism one boundary out: rest walks RestServer.getRoutes() (88 routes, 45 write) with a total disposition map and drives the 10 data-plane ones including POST /batch, the direct-ql.update mouth the card names; mcp walks the McpDataBridge faces the stdio factory returns. IMPORTANT: widening the guard found a LIVE LEAK the card did not know about. @objectstack/mcp's stdio bridge is engine-only and its create arm echoed engine.insert's result — whole since #7823 relocated the strip off the engine — straight to the MCP caller, measured as vault_secret riding the tool response verbatim. Fixed in scope (a guard shaped to exclude the one place the property is false would repeat the exact mistake the card is about), plus update, so a caller cannot use their own patch as an oracle on an internal column. The strip helper moved from metadata-protocol to @objectstack/core because rest and mcp both write through the engine directly and NEITHER depends on metadata-protocol — the old home forced each new mouth to duck-type through a protocol instance or restate a security rule; core is the floor all three share and already hosts bulk-write.ts, the same class of shared helper. metadata-protocol re-exports both names, verified against the rebuilt dist. rest-server.ts itself is unchanged.", "tests": "4108 tests green across the four packages: core 786/786, mcp 182/182, metadata-protocol 1245/1245 (incl. the untouched #7823 tripwire), rest 1895/1895. New tripwires: rest 14/14, mcp 12/12. REVERSE VERIFICATION — 3 experiments, direction predicted RED before each run, fix committed first so restores came out of a real commit: (1) delete the strip from the REST POST /batch update arm -> RED on POST /api/v1/batch only, leaked body '{\"results\":[{\"name\":\"CONTROL...\",\"id\":\"r_2\"},{\"id\":\"row-1\",...,\"vault_secret\":\"INTERNAL-SENTINEL-8497-NEVER-SERIALIZED\"}]}' — the create arm (protocol ingress) stayed clean, so the guard names WHICH mouth leaked; (2) the family's acceptance criterion — add a SECOND unstripped direct engine mouth (batch create arm -> direct ql.insert) -> RED again, leak moved to the new mouth while the stripped update arm stayed clean; (3) delete the new MCP create strip -> RED on create. All restores byte-identical by git hash-object: rest-server.ts 6a58de8f15a9565f5628553666a4cff8796db305 (twice), stdio-data-bridge.ts 53e08e6aa6136169723b6b7ba7317b77a278a52a. Anti-blindness: every driven case also demands a CONTROL value (a refusal or 501 cannot satisfy 'no sentinel' by returning nothing), plus a test asserting the fixture's stored row really carries the flagged value. GATES (derived via scripts/pm/dispatch-gates.mjs on actual changed paths + judged-implicated): check:nul-bytes, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:durability-log-level, check:kernel-hook-pairs, check:test-source-alias, check:engine-double-contract, check:query-options-erasure, check:type-check-coverage, check:type-check-debt, check:objectui-changeset, eslint, typecheck (mcp+rest) — ALL PASS. check:type-check-debt initially FAILED (+2 raw tsc errors vs the frozen rest TEST_DEBT entry): packages/rest hides its tests from its own typecheck script, so the package typecheck was green while the ratchet moved — both errors in my new file (missing .js extension under NodeNext; registry.registerObject missing its required packageId). Fixed rather than ledgered (TEST_DEBT is shrink-only, raising it is maintainer-only); re-measured at exactly 155, the recorded value. It also needed the full workspace build closure to run at all — its first failure was a precondition, not a verdict.", "open_questions": [], "out_of_scope_findings": [ "filed as #8589 (label `finding`, unqueued): McpDataBridge has TWO implementations and the new mcp tripwire walks only the stdio one — the runtime's buildMcpBridge routes through callData (protocol path, correct today, not a live defect) but no guard walks it, so the same class of gap remains one level in. Searched first; no duplicate.", "not filed, deliberate: rest-server's mouth reaches the helper via an optional duck-typed call `(p as any).omitInternalWriteFields?.(...)` that silently no-ops if the method ever disappears. Left as-is (minimal diff, it works today) and now covered by measurement rather than by reading — the new REST tripwire drives the real protocol, so the no-op would surface as a leak. Noted in the PR body." ] }
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026
Filed unassigned by the
domain:engine-coreseat (#6019), sessionsession_01RDTnVvsgA6cUZ4xFVtPZRy, from reading PR #7996 (#7823) at landing. ⛔ Observation-class, ⛔ not queued, ⛔ not a defect in that PR — its own fix covers this mouth. Triage grades it.The fact
#7823's A-prime ruling relocated the internal-field write-response strip to the generic-data-path ingress, and gated the relocation on a tripwire — because "a future generic ingress that forgets the shared helper leaks silently."
⭐ The tripwire that shipped is genuinely strong:
protocol.write-response-internal-fields.tripwire.test.tsenumerates every*Datamethod on the protocol class at runtime (a prototype walk by naming convention, ⛔ not a hand-written list), requires each to have a registered recipe or fails with instructions, and carries a negative control (leakyData) proving both the walk and the sentinel scan go red on a real leak. It was reverse-verified: helper removed fromcreateData⇒ RED; restored ⇒ green, byte-identical bygit hash-object.That is the REST cross-object batch's direct
ql.updatecall. The shared helper is correctly applied there, so ⛔ nothing leaks today. But a prototype walk over the protocol class cannot see it — so if someone later adds a second direct engine write mouth inrest-server.ts(or in any other package that calls the engine directly rather than throughmetadata-protocol), the tripwire stays green while the new mouth leaks.⇒ The guard's coverage is "every
*Dataface on the protocol class". The property that actually needs guarding is "every response body an external caller can receive from a write." Those two were the same set on the day the tripwire was written, and therest-servermouth is the standing proof they are not the same set by construction.Why this is worth recording rather than shrugging
internal: truewas honoured at three places and the author of a new surface had no way to know which; the fix made the rule structural at one boundary, and the guard structural at one class. The gap between "the boundary" and "the class" is exactly where the next silent leak fits.⭐ Note also what this card is not claiming: the tripwire is not weak. A runtime prototype walk with a live negative control is materially better than the hand-kept list the ruling feared, and it does catch a genuinely new
*Dataface. ⛔ Do not read this as a reason to redesign it — the question is whether its scope should widen, not whether its mechanism is right.Possible directions (⛔ not prescriptive, no ruling implied)
omitInternalFieldsFromWriteResponse.internal: truevalue), independent of which code path produced it.metadata-protocolare the author's responsibility —Refs
#7823 · PR #7996 · #7728 (the
internal: trueflag) · #8118 (Engine.resolveInternalField) · #8494 (a structurally identical "the instances are fixed, nothing holds them fixed" card from #7620).