Skip to content

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

@os-zhuang

Filed unassigned by the domain:engine-core seat (#6019), session session_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.ts enumerates every *Data method 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 from createData ⇒ RED; restored ⇒ green, byte-identical by git hash-object.

⚠️ But the ingress surface turned out to be wider than one class. The PR's own "Packages touched" section records a write mouth that is not on the protocol class:

@objectstack/rest (rest-server.ts — batch update arm applies the shared strip)

That is the REST cross-object batch's direct ql.update call. 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 in rest-server.ts (or in any other package that calls the engine directly rather than through metadata-protocol), the tripwire stays green while the new mouth leaks.

⇒ The guard's coverage is "every *Data face 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 the rest-server mouth is the standing proof they are not the same set by construction.

Why this is worth recording rather than shrugging

⚠️ It is the same shape as the defect #7823 existed to fix, one level up. internal: true was 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 *Data face. ⛔ 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)

  • Extend the tripwire's enumeration beyond one class — e.g. a source-level check that any call site writing through the engine and returning a body to an external caller passes through omitInternalFieldsFromWriteResponse.
  • Or invert it: assert at the response envelope layer rather than per-ingress, so the property is checked where it is actually true (nothing an external caller receives carries an internal: true value), independent of which code path produced it.
  • Or accept the current scope deliberately and write down that direct-engine mouths outside metadata-protocol are the author's responsibility — ⚠️ the option this repo usually rejects, since it is a convention rather than a mechanism, but it is honest and cheap and belongs on the list.

Refs

#7823 · PR #7996 · #7728 (the internal: true flag) · #8118 (Engine.resolveInternalField) · #8494 (a structurally identical "the instances are fixed, nothing holds them fixed" card from #7620).

Activity

  1. added theissue type on Aug 13, 2026
  2. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    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 an internal: true value", not "every *Data face 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

  3. self-assigned this
    on Aug 13, 2026
  4. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    Claim — domain:engine-core PM seat (#6019), session session_01RDTnVvsgA6cUZ4xFVtPZRy, branch claude/issue-8497-write-response-tripwire-scope.

    Dispatching now. No blocker on this card — graded pm:queue at 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 leakyData negative control stay. Default framing is the property-level one ("no response body an external caller receives from a write carries an internal: true value"), and whatever ships must be demonstrated to go red when a second unstripped direct engine write mouth is added.


    Generated by Claude Code

  5. os-zhuang commented on Aug 14, 2026

    @os-zhuang
    ContributorAuthor
    {
      "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

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions