Skip to content

fix(runtime): a sandboxed body or an action handler reading the stored-metadata tables is served the data door's form (#21454) - #21513

Merged
objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-21454-reach-measurement
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-21454-reach-measurement

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Part of #21454

Clause-②: yes

The stored-metadata-body family serves a stored metadata body only as its type's read projection, and the stored content hash over it only in keyed form, at every door. Step 1 of this card measured three in-process reader contexts serving the two stored-metadata tables as stored, body and hash alike. The action contexts run elevated, so a member who invokes the action receives the same answer as an administrator. Triage ruled disposition A: apply the family's projection and keyed serve at the reader-context seams, consume the shared functions, and leave the engine alone. This PR does that for every context the measurement found. It is Part of, not Fixes: the flow reader that triage folded into this card was measured and is reached, but its fix lies outside this claim's surface (see "Not closed by this PR").

Disclosure discipline: classes, doors, roles, codes and forms only.

How

  • packages/runtime/src/stored-metadata-reader-seam.ts (new). One serve, built only from the generic data door's own functions:

    • the type companion for a projection that names the body without the type;
    • the body projection, which consumes the family's one redactor in @objectstack/spec/kernel;
    • the keyed serve, under the crypto provider's digest or, when none is registered, the data door's own process-scoped ephemeral key.

    serveStoredMetadataRead serves one read. serveStoredMetadataReadsThrough serves a scoped API: find, findOne and aggregate on a family object, through every context the API can derive (sudo(), withRunAs(...), a transaction(...) callback's context and the context beginTransaction() returns). It is idempotent, so a hash is never keyed twice. Writes, count and every other object pass through unchanged.

  • sandbox/body-runner.ts, buildSandboxApi. Both body faces (hook and action) get their ctx.api here, every fallback included. That API is now served through the seam, so a hook body that copies what it read can only copy the projected form.

  • action-execution.ts. Two call sites are served through the seam: buildActionApi, the ctx.api an action body and a host code handler both receive, and buildActionEngineFacade's find. Both action doors (REST /actions and MCP run_action) build these two, so both doors are covered.

  • packages/metadata-protocol/src/index.ts. Exports the data door's four stored-row serve functions and the digest type, so the runtime consumes them rather than copying them. The exports are additive and change no behaviour.

  • scripts/engine-double-contract.pinned.json. One new pinned row: the seam unit test's scoped-API double routes findOne through the engine's predicate. Written by the gate's own --write.

A declared in-place widening. The handler's ctx.api (③ in the table below) was not one of the two contexts the ruling named. It is the same scoped context object an action body receives (buildActionApi), and it was measured reaching (administrator and member: stored body, stored hash). The fix is the same call, in a file this claim already holds. All four in-place conditions hold, so it lands here and the pins cover it.

Readings: before and after

context door role before after
① action body, ctx.api.object(...) REST actions administrator, member stored body, stored hash (both tables) projected body, keyed hash
① action body, inside ctx.api.transaction(...) REST actions administrator, member stored (administrator, measured this run) projected, keyed
① hook body that copies what it read into an ordinary record data-door insert administrator stored content landed in the record only projected content lands
① hook body data-door insert member refused, 403 PERMISSION_DENIED unchanged
① action body authored at runtime through /meta metadata, then actions administrator authors; administrator and member invoke stored projected, keyed
② handler ctx.engine.find REST actions administrator, member stored (both tables) projected, keyed
③ handler ctx.api.object(...) REST actions administrator, member stored (measured this run) projected, keyed
control: generic data door data administrator projected, keyed unchanged
control: generic data door data member refused, 403 PERMISSION_DENIED unchanged

For every row marked "after", the pins assert four things:

  • no stored credential and no stored hash anywhere in the answer;
  • the row's non-credential configuration is present, so the body is the projection of this row, not one that was withheld;
  • the served hash equals the keyed value the data door serves for the same row, which shows one serve and one key.

Tests

  • Pins. packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts is step 1's probe with its context cases flipped from RECORD to ASSERT. It boots the bootStack plugin set, in order, once in beforeAll (never inside a case) and runs 15 cases. packages/runtime/src/stored-metadata-reader-seam.test.ts holds 8 unit cases for the routes around the seam that the composition does not exercise one by one. Both files are green at 532c96d70f (15/15, 8/8).
  • Runtime suite. At 532c96d70f, pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 answered: Test Files 311 passed (311), Tests 4393 passed, 19 skipped. --project repo at 265f1c1f32: Test Files 3 passed, Tests 751 passed.
  • metadata-protocol suite. At 265f1c1f32, pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 answered: Test Files 205 passed, 3 skipped; Tests 3092 passed, 19 skipped. After that sha the package's only change came in through the main merge, and it did not touch this package.
  • Typecheck. At 532c96d70f, pnpm --filter @objectstack/runtime typecheck (tsc --noEmit plus check:test-typecheck, OK, ledger unchanged) and pnpm --filter @objectstack/metadata-protocol typecheck were both green.
  • Built artifacts. Both the CJS and ESM builds of @objectstack/runtime load, and both builds of @objectstack/metadata-protocol export the four functions.

Reverse verification (forms only)

The fix was committed first, at 265f1c1f32. Then:

  • Mutation leg. The three exercised seam calls (the sandbox API, the action ctx.api, the engine facade's find) were replaced through scripts/ablation-replace.mjs. Each anchor went from 1 to 0 and each blob changed. The runtime was rebuilt, and scripts/ablation-dist-preflight.mjs found the mutation marker in both dist bundles. The pins then answered 10 failed, 13 passed. Every context case failed with "the stored credential reached the answer". The precondition, the three controls, the member hook refusal and the 8 seam unit cases stayed green.
  • Restore leg. A trap ran git checkout HEAD -- on the two paths. Both blobs equal HEAD, git diff HEAD is empty and the porcelain is clean. After a rebuild, the preflight with --absent was clean and the pins answered 23/23 green.
  • Direction. The expected direction, red. buildActionApi's malformed-envelope fallback is the one seam call no case exercises, so it was not mutated.

Gates

  • Derived gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack was derived with no paths at 532c96d70f and gave 72 lines. All 72 were run at that head, each exited 0, and --ran reconciled them: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN.
  • CJS load gate. check:dual-build-cjs-loads answered PREREQUISITE NOT MET on an earlier head. It was re-run once the tree's packages were built, and it measured 106 entry points across 66 packages.
  • check:engine-double-contract asked for the new pinned row above.
  • check:slot-lookup asked for the pins' handler plugin to read the engine slot through its contract type. Both are fixed in this PR.
  • Lint, a proven narrowing. pnpm lint (the repo-wide eslint .) was not run locally; CI runs it.
    • Population, read from eslint's own config: 6 of the 8 changed paths are linted. The changeset and the JSON ledger answer "no matching configuration".
    • Count, read from --format json at 532c96d70f: 6 files, 0 errors, 0 warnings.
    • Invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project, no typed rules) and carries no cross-file import rules. Its custom plugins and baselines are per file, so this diff cannot move the verdict on any untouched file.

Not closed by this PR (measured; reported on the card for routing)

  1. The flow record-read node. Triage folded it into this card, to be measured first. It is reached. Under both run identities, a flow's record-read node answers the stored body and the stored hash. That answer goes into the run's declared output, which the flow-action door returns to its caller, and into any record the flow writes.
    • How it was measured: in the service-automation tier, on an in-process engine with the automation service, with the row seeded in its stored form. No security plugin was loaded, so the run identities are not role-gated there.
    • Why it is not edited here: the keyed serve cannot be reached from @objectstack/service-automation, which does not depend on @objectstack/metadata-protocol. Consuming it there needs either that dependency edge or the keyed serve moved beside the body primitives in @objectstack/spec/kernel. Both are outside this claim's surface, and the second is packages/spec.
  2. Evaluate shapes through these contexts. A predicate over the body column, run through a body's count, answers whether a guess matches (measured, member invoking). The data door refuses these shapes. The ruled disposition changes only what a read serves, and so does this PR.
  3. An object name the engine's registry does not resolve. The engine's in-process verbs hand such a name to the driver, and the driver can resolve it to a family table.
    • The seam judges the object by the family's own predicate on the name, exactly as the data door does. The data door, however, also refuses an unregistered name (404 OBJECT_NOT_FOUND), and the in-process verbs do not.
    • Measured after this fix: an action body, invoked by an administrator or a member, still receives the stored body and hash this way.
    • This bypasses every name-keyed in-process guard, not only this one, so the fix belongs in the engine.
  4. A hook bound to a family object. It receives the stored row in its own hook context and can copy it (measured: fired by the metadata door's own save). That row does not come through ctx.api, and this PR does not project it, because a hook's input is also its write-back channel.
  5. A write through these contexts. It returns the written row in its stored form (measured for an elevated body's update). Under dispatch assumption A3 this is reported, not widened into this PR. The same measurement shows that an elevated body can write the family tables directly.

Acceptance notes

  • The engine's ScopedRepo.execute() hands a code handler a raw scoped context that no seam wraps. Its own census found zero callers. That is engine lane: noted, not filed.
  • Changeset: .changeset/21454-reader-context-family-serve.md, patch for @objectstack/runtime and @objectstack/metadata-protocol.

Generated by Claude Code

claude added 11 commits October 2, 2026 22:58
…-metadata-body family (wip)

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-authored-by: Claude <noreply@anthropic.com>
…condition, refusal codes (wip)

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-authored-by: Claude <noreply@anthropic.com>
…control and both contexts (wip)

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-authored-by: Claude <noreply@anthropic.com>
…rve for the in-process reader contexts

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-authored-by: Claude <noreply@anthropic.com>
… the test typecheck

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-authored-by: Claude <noreply@anthropic.com>
…e engine predicate, pinned in the double ledger

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-authored-by: Claude <noreply@anthropic.com>
… its contract type

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/runtime, touching 16 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-flow.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/automation/hook-bodies.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/automation/webhooks.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/kernel/contracts/data-engine.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/kernel/contracts/index.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/kernel/events.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/permissions/attachments-access.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/permissions/field-level-security.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/permissions/record-view-auditing.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/permissions/rls.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/permissions/system-context.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/protocol/objectql/query-syntax.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/protocol/objectql/schema.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/ui/react-pages.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))

⛔ 6 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/releases/v16.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/releases/v17/17-0.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/releases/v17/17-5.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/releases/v17/17-6.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))
  • content/docs/releases/v17/index.mdx (via findOne (literal, a string literal in ROW_SERVING_READS))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 31 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f9a8eb889ead684cb3f35f4ee013e634513ad1af → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ef064c15277ee33057fc686f55fe9bef78eaffd9 — the merge of head c69c33396dc50ea173464a5d09cc4e7f731b8d9c into base f9a8eb889ead684cb3f35f4ee013e634513ad1af, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ef064c15277ee33057fc686f55fe9bef78eaffd9 && git checkout ef064c15277ee33057fc686f55fe9bef78eaffd9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f9a8eb889ead684cb3f35f4ee013e634513ad1af c69c33396dc50ea173464a5d09cc4e7f731b8d9c && git checkout -B drift-repro f9a8eb889ead684cb3f35f4ee013e634513ad1af && git merge --no-ff c69c33396dc50ea173464a5d09cc4e7f731b8d9c

node scripts/docs-audit/affected-docs.mjs --json f9a8eb889ead684cb3f35f4ee013e634513ad1af

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f9a8eb889ead684cb3f35f4ee013e634513ad1af → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
…inor under Clause-② yes

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c69c33396dc50ea173464a5d09cc4e7f731b8d9c
Local-runs: none

Inputs: card #21454 (body and all 11 comments, rulings 5960355158, 5963299937, 5964426684 included), PR #21513 (body, 8-file list, net diff against main), and the check-runs on the head. Read-only. Classes, doors and roles only.

① Derived judgments

  1. Public surface, @objectstack/metadata-protocol (src/index.ts). Four functions and one type that were module-internal door primitives become exports: storedMetadataBodyProjection, redactStoredMetadataRows, serveStoredMetadataHashColumnRows, ephemeralStoredHashDigest, type StoredHashDigest. Additive, no behaviour moved. Exporting the ephemeral digest shares the ONE process key with every in-process consumer, which is the intent (one key, one keyed form per row); a consumer can compute a keyed form, never read the key. Right. No API-surface baseline names this package, and Lint and Repo Gates is green.
  2. Accept set at the reader seams (stored-metadata-reader-seam.ts). On an object the family predicate names (the door's own predicate from spec/kernel), the row-serving verbs find, findOne, aggregate are served; count, the write verbs and execute pass through bound; every other object passes by reference, query untouched. Judged against the scoped repository's actual member set (find, findOne, insert, update, delete, count, aggregate, execute): the three named are exactly its row-serving reads. Right. (Write-verb returns and the count predicate oracle are routed, see ③.)
  3. Derived contexts. object(name), sudo(), withRunAs(...), the transaction(fn) callback context and beginTransaction().ctx are re-served; commitTransaction/rollbackTransaction take a handle, not a context, and pass through; withRunAs may answer the unscoped hook api, which refuses every data door, so wrapping it is harmless. Served-once marker makes the double wrap (action door, then sandbox) a no-op, so a keyed hash is never keyed twice. Checked against the scoped context's member set. Right.
  4. Projection widening. A fields list naming the body column without the type column gets the type read beside it and taken back off (dropType), the door's own rule; a row the redactor cannot judge (no type beside the body, as in an aggregate group that did not group by type) fails closed with the body omitted, as on the door. Right.
  5. Keyed serve. The crypto provider's keyed digest is read off the engine per use, else the process-scoped ephemeral key: the door's two sources in the door's order. A host engine without the accessor keys under the ephemeral key, which is still never the stored value. The composed pins assert equality with the door's served hash per row id, so one key is proven in the assembled stack. Right.
  6. Seam placement against ruling 5963299937 (disposition A). buildSandboxApi wraps both body faces and every fallback (the one place both faces get ctx.api); buildActionApi wraps both branches; buildActionEngineFacade.find is wrapped, and find is that facade's only row-returning verb (insert returns an id, update void, delete void). Both action doors build these two (REST /actions in domains/actions.ts, MCP run_action in action-execution.ts). Engine untouched (not B), no refusal (not C). The facade's envelope check runs on the caller's query before the seam widens fields. Right.
  7. Pins. The step-1 probe is inverted into assertions: each context, administrator and member, asserts no sentinel and no stored hash anywhere in the answer, this row's non-credential configuration present, and the served hash equal to the door's keyed value for the same row id; the data-door control and the member refusals are unchanged; the hook copy exit lands only projected content. The seam unit file covers the routes the composition does not, and its double's findOne is pinned in the engine-double ledger by the gate's own writer. Right.
  8. Check-runs on the head. Latest run per name: every one is success or skipped (skipped: Build Docs, Console Pin Gate, the opt-in tarball smoke, and the superseded Auto Label and Check PR Size re-runs). No failure, nothing pending. Gate verdicts: green.
  9. Governed surfaces. None in the file list; this review is owed under Clause-②: yes, not under a tier.

② Semver level

  • Changeset .changeset/21454-reader-context-family-serve.md: @objectstack/runtime patch, @objectstack/metadata-protocol minor, body line Clause-②: yes, no arm.
  • Right. The export widening is Clause-② by the lanes rule (放宽接受集或扩大公开面的卡,不论多小,即条款②), and the widened package is graded minor, which is what yes takes. The runtime half is a bug fix in a released package with no public-surface change (the seam is not exported from the runtime index), so patch; a runtime security-behaviour change is not itself Clause-②. No (narrowing) arm: nothing an author can write is removed or renamed, and the stored form was never the published contract, so no ADR-0087 marker is owed. skip-changeset does not apply.
  • Clause-②: line in the PR body: yes, matching the changeset (corrected by the seat; Check Changeset is green on this head after the edit).
  • Changeset prose checked against the diff: the three contexts, the three verbs, the four derived contexts, what does not change, and the five new exports are named exactly. Right.

③ Boundary flags

Dev report 5964258278 (deviations 1 to 7, open_questions, out_of_scope), patch round 5964334002, seat ACCEPT 5964347376, triage 5964426684:

  1. Part of, not Fixes: answered (seat accepted partial; triage keeps the card open; the Part-of gate is green).
  2. Cross-lane metadata-protocol/src/index.ts: answered, declared by the seat on [PM seat] domain:engine · seat 2 — 🟢 os-bill #20966 per 5964347376; additive only.
  3. engine-double-contract.pinned.json row: answered, written by the gate, ungoverned.
  4. In-place widening to the handler's ctx.api: judged right. It is the same scoped-context class the ruling's sandbox seam serves, measured reaching for both roles, in a file the claim held, and pinned; leaving it unserved would have left a measured route open under the same ruling.
  5. Evaluate-shape refusals absent at the seams: answered, triage item 2, this card's next claim.
  6. and 7. Comment count, trailers: no contract content.

Escalated, unanswered on the card:

  • The repository's execute verb (the engine's own action path) hands a code handler a raw scoped context that no seam wraps; the dev's census found zero in-repo callers and wrote it into Acceptance notes as "noted, not filed". It is reachable in principle from a served body, since the seam passes execute through, and triage's routing answer does not name it. Ask triage: fold into this card's next claim (same seam class) or file in the engine lane. Not a wrong in this diff.
  • Observation for the family census, not a dev flag: host-code hooks registered directly with the engine receive the engine-built hook api, not the sandbox's, so this seam does not serve them. Deployer-authored, not a context the card named, not measured. Routing, not this PR.

Implemented-by: claude/issue-21454-reach-measurement
Reviewed-by: session_016GiHYRmLSNWTfbX9gVQkpz

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 02:31
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 02:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit abe8f28 Oct 3, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21454-reach-measurement branch October 3, 2026 03:04
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…serve write returns at the reader-context seams (objectstack-ai#21539)

Part of objectstack-ai#21454

Clause-②: yes (narrowing)

The follow-on to PR objectstack-ai#21513, on the same reader-context seam. objectstack-ai#21513
served the stored-metadata-body family's reads (body projected, content
hash keyed) at the in-process reader contexts. This PR closes the two
positions triage routed to the card's next claim, through the generic
data door's own code — no copy:

- **Evaluate-shape refusals** (triage `5964426684` item 2, with
`5963299937`). A filter, sort, grouping or search that would EVALUATE
the stored body or a content-hash column of the family, arriving through
a reader context (`ctx.api.object(...)` for action and hook bodies, a
handler's `ctx.api`, and `ctx.engine.find`), is now refused with the
door's own `INVALID_FIELD` / 400 before the query runs. A `count`
carrying such a predicate — the oracle verb — is refused too (it serves
no row). A default search is NARROWED to the door's served field set
(the body and content-hash columns removed, judged field by field by the
door's own search predicate) rather than refused, so a reader context
may still search a family table by its scalar columns exactly as the
door serves it; a search that narrows to nothing is refused.
- **Serve what a write verb returns** (triage `5964426684` item 4). A
write whose return carries the family's body or content hash is served
projected and keyed, the same way a read is — a returned row is a serve.

Both are executed through the four refusal predicates the door uses, now
exported from `@objectstack/metadata-protocol`
(`storedMetadataBodyGroupingRefusal`,
`storedMetadataBodyPredicateRefusal`,
`storedMetadataHashEvaluateRefusal`, `storedMetadataSearchRefusal`). The
search narrowing consumes the spec's own `resolveSearchFieldResolution`
and the door's search predicate as the authority on which columns a
search may never scan. Field collection for the filter/sort refusals
uses `@objectstack/plugin-security`'s `collectConditionFields` (the
door's sibling collector; the door's own `collectFilterFieldKeys` is
internal to `protocol.ts`, PR objectstack-ai#21473's file, and unreachable here). That
collector gates on a dotted head and reads a cross-field reference, so
it refuses MORE than the door — a dotted or cross-field reference to a
family column the door's collector would miss — strictly in the safe
direction, never a legitimate scalar-column query.

### The engine action verb (`ScopedRepo.execute`), measured (triage
`5964836549` item 1)
The reach reading: `execute` is UNREACHABLE from a served body. The
sandbox VM bridge exposes only `find` / `findOne` / `count` /
`aggregate` and the writes to a body — no `execute`, no `sudo`, no
`withRunAs` — so a served body can never hand a raw scoped context to a
nested action. The seam therefore leaves `execute` untouched and records
the reading (pinned: a body's `typeof ctx.api.object(...).execute` is
`undefined`).

### Where objectstack-ai#21520's write-verb refusal attaches
The maintainer approved objectstack-ai#21520 option A (an app-authored body may not
write the family's tables). That refusal is a SEPARATE card and is NOT
implemented here. It attaches on the same write verbs this seam wraps,
in `serveRepository`'s write branch, as a throw BEFORE the write runs —
it needs no reshaping of this seam. A code comment names that point.
Measured on `main` today (pre-objectstack-ai#21520): an elevated body's family-table
write is not refused and returns the stored row, so the write-return
serve in this PR carries real content; it also covers the host-handler
write path, which objectstack-ai#21520's body refusal does not reach.

### Reverse verification (ablation)
Each new behavior ablated on disk (`scripts/ablation-replace.mjs`,
mutation proven by blob hash + anchor count, restored to the HEAD blob),
src-resolved (the pins import the seam by relative path):
- disabling the evaluate refusal turned the 6 refusal / narrowing pins
red; the serve, write-return, scalar and unreachability pins stayed
green;
- disabling the write-return serve turned exactly the write-return pin
red; all else green.

### Tests and gates
- New pins: `stored-metadata-reader-seam.test.ts` (17) against a
scoped-API double, and `stored-metadata-reader-contexts.pin.test.ts`
(26) end to end through a booted kernel, for administrator and member
alike.
- `@objectstack/runtime` suite 4413 passed / 19 skipped;
`@objectstack/metadata-protocol` suite 3151 passed / 19 skipped; both
typecheck clean.
- All 70 dispatch-derived gate families green
(`check:engine-double-contract` pinned-ledger entry added through the
gate's own `--write`).
- Lint narrowing: the 4 changed TS files lint 0 errors / 0 warnings; the
`.md` and `.json` have no matching eslint config; `eslint.config.mjs`
enables no type-aware linting and no cross-file import rules, so this
diff cannot move the verdict on any untouched file (the farm-wide `pnpm
lint` is CI's).

---
_Generated by [Claude
Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…egistry does not resolve (objectstack-ai#21516) (objectstack-ai#21545)

Fixes objectstack-ai#21516

Clause-②: yes (narrowing)

An in-process engine verb now refuses an object name the schema registry
does
not resolve with the data door's own `OBJECT_NOT_FOUND` (404), instead
of
handing that name to the driver as a raw table name. One name space for
the
in-process verbs and the generic data door (triage ruling). The engine's
accept
set narrows; no surface is widened. The `yes` half of the clause line is
the one
new export, `objectNotFoundError`, in `@objectstack/core`.

## What changed

- **`packages/core` (new `objectNotFoundError`).** One factory for the
`OBJECT_NOT_FOUND` / 404 envelope, beside `recordNotFoundError` and for
the
same ADR-0076 D2 reason (the engine closure cannot import the package
where
the door's envelope was written). Both doors now build the refusal here.
- **`packages/metadata-protocol` (the door).** `assertObjectRegistered`
raises
  that shared factory: same wire status, same code.
- **`packages/objectql` (the engine).** `resolveObjectName` throws
`objectNotFoundError` for a name the registry does not resolve, rather
than
  returning it as a physical table name. Every in-process verb
(`find`, `findOne`, `count`, `aggregate`, `insert`, `insertMany`,
`update`,
  `delete`, `validate`) resolves through it, so all refuse uniformly: no
spelling allow-list, no per-caller marker. `judgeFilter` keeps judging
the
filter for an unresolved name (it reads nothing and reaches no driver),
as its
  contract states.
- **Three platform-internal, constant-name best-effort probes** that
read a
known system object and were already fail-soft on a missing table now
treat
  the engine's refusal (attributed to their own object) as the same "not
provisioned in this composition" case. A body cannot reach these paths:
  `ObjectQL.probeInstallOrganizations` (registry-presence guard),
`SeedLoaderService.resolveSoleOrganizationId` and
`SysMetadataRepository`'s
  history counters (refusal recognised by `code` and `object`).
- **`packages/spec`.** The `IObjectQLEngine.judgeFilter` docblock states
that
execution refuses an unknown object before admission (comment only; it
ships
  in the built type declarations).

## Why (classes, doors, roles, codes only)

An action body invoked through the actions door could name a protected
member
of the stored-metadata family by a spelling the registry does not
resolve and
receive its stored content, whether a member or an administrator invoked
it.
The in-process verb handed that name to the driver as a raw table name,
and
every name-keyed in-process guard (PR objectstack-ai#21513's reader seam among them)
was
addressed by the registered name only. The generic data door answers
`OBJECT_NOT_FOUND` for the same name. The engine now answers the same,
so the
two doors share one name space and no name-keyed guard can be stepped
around by
naming its target some other way.

## Census: does any legitimate platform reader rely on the raw-table
fall-through?

Instrumented the resolver's fall-through and ran the `objectql`,
`metadata-protocol` and `runtime` suites, plus a full boot, seed and
door drive
of four example apps (crm, showcase, todo, multi-package). The
instrument was
reverted in the branch; the net diff carries none of it.

| composition | fall-through reads | reader relies on it? |
|:--|:--|:--|
| 4 example apps booted, seeded, driven through the doors | **0** | no:
every platform reader addresses a registered object |
| unit/integration suites (partial registries + tolerant stub drivers) |
many | only test harnesses, plus the three constant-name probes below |

Every in-repo caller that passes a possibly-unresolved name, by
function:

| caller | object (constant) | pre-change | disposition |
|:--|:--|:--|:--|
| `ObjectQL.probeInstallOrganizations` | the org object | fail-soft on
missing table | moved: registry-presence, empty answer |
| `SeedLoaderService.resolveSoleOrganizationId` | the org object |
fail-soft on missing table | moved: recognise the refusal as
not-provisioned |
| `SysMetadataRepository` history counters | the history object |
fail-soft on missing table | moved: recognise the refusal as
not-provisioned |
| `ObjectQL.cascadeDeleteRelations` / `planCascadeAtomicity` /
`referenceExists` | a relation ref | already `try/catch` | unchanged:
already tolerate a throw |
| the data door's existence gate | the requested name | raised the 404
itself | now raises the shared factory |

Conclusion: **no production or example reader relies on the
fall-through.**

## Merge-queue fix

`os migrate account-issuer` reads `sys_account` through the driver the
engine routes that name to. Its read-only boot registers no
`sys_account`, and the engine now refuses an unregistered name. The
refusal is not read as absence, because that would report a table full
of accounts as a clean pre-flight. objectstack-ai#21570's missing-table reading for
`sys_account` is kept, and every other failure still throws.

## Fixture triage (the test-only fallout, per the seat's answer)

Every test that encoded the raw-table fall-through, by disposition. No
ADR text
is edited, and no pin ruled under objectstack-ai#7929 (the cross-field withholding
decision)
changes what it asserts. The table-keyed `captureExpectedReadRefusals`
noise
pins keep their subject and reshape their reading (item 3).

1. **The unregistered name is the deliberate probe: the test now asserts
the
refusal** (`code` + `status`, and where the test watched the driver,
that
   the driver saw nothing).
   - `objectql`: `engine-20822-no-field-map-type-blind-lowering`,
     `query-expression-conformance`, `engine.test`,
`engine-undeclared-update-field`, `engine-undeclared-field-preflight`,
     `engine-temporal-comparand-door`, `engine-aggregate-filter` /
`-having` / `-reference-verdict`, `engine-summary-recompute-context`,
     `registry-field-type-refused-at-door`, the `global-search-*` pins,
     `engine-judge-filter`, `engine-organization-probe-outage`.
- `protocol-unregistered-object.test.ts`, case B of the card 3770 gate:
the
     door's 404 assertion is unchanged; the engine assertion turns from
"serves the row" to "refuses `OBJECT_NOT_FOUND` / 404"; header item ②
     gains one sentence naming objectstack-ai#21516.
- dogfood `registry-gate-wiring`: the premise reads ground truth at the
driver (host code's declared internal path), then asserts the engine
     refuses the same name.
2. **The fall-through was incidental: the harness now registers what the
platform reader resolves** (registered after boot or DDL, so nothing new
is
   provisioned and every outage/absence subject keeps its meaning).
   - `objectql` metadata-write harnesses (delete, save, publish-meta,
     publish-package-drafts, protocol-derived-provenance,
     protocol-save-meta-repo-path, protocol-picklist,
     protocol-publish-canonical-fold): the stored-metadata family.
   - `rest` (14 harness files): the stored-metadata family, after DDL.
- `plugin-security` (4 files) and the `http-conformance` stack: the
authz
resolver's read set, unprovisioned, so the missing-table answer is still
     what they measure.
- `plugin-approvals` status-mirror cascade: the delegation object and
the
     org object, unprovisioned. `service-settings`: the secret and
     setting-audit objects.
3. **Noise pins: same subject, reshaped reading.** The org probe for an
unregistered org object is now refused before any driver, so a pin that
read
"the probe reached the driver and was withheld" now reads "the probe
reached
no driver" (`tablesSeen()` equal to empty where `silentChannels()` was
   read). `runtime` (about 17 files) and `trigger-record-change`.
`expected-read-refusal-noise.channel-asymmetry.test.ts` registers its
probe
object, unprovisioned, so the real driver refusal is still what its two
   channels measure.
4. **`cli` served-boot control** (`schema-migrate.host-composition`):
each
hook's probe read is witnessed by its recorded `OBJECT_NOT_FOUND`
answer, not
by a driver line; the SQL driver suppresses that line for its own
deferred
   set, so the line never was the subject.
5. `engine.test.ts`: one mock parameter typed (`name: string`), the
   `@objectstack/objectql#typecheck` red of the earlier heads.

## New pin: the measured public door

`packages/runtime/src/unresolved-object-name.actions-door.pin.test.ts`
boots
the plugin set `bootStack` uses and drives REST `/actions`. The target
is a
table that exists and holds a sentinel row, created out of band at the
driver
and registered nowhere (the class the card measured, naming no protected
table). For an administrator and a member, the action body's read
answers
`404 OBJECT_NOT_FOUND` and the sentinel appears nowhere in the answer.
Control:
the same body shape on a registered name is served. Reference: the
generic data
door's answer for the same name is the same 404. PR objectstack-ai#21513's reader-seam
pins
stay green.

## Ablation (one-shot; nothing left in the tree)

Mutation leg, `scripts/ablation-replace.mjs` on `engine.ts`: the refusal
in
`resolveObjectName` replaced by the old raw-table return plus a marker
branch
(marker on disk 1, refusal on disk 0); rebuilt;
`ablation-dist-preflight`:
marker present in 4 built files. Pins under mutation:

- objectql (`protocol-unregistered-object`, `engine-20822-...`,
  `query-expression-conformance`, `engine-judge-filter`):
  `5 failed | 245 passed (250)`
- runtime actions-door pin: `2 failed | 4 passed (6)` (both role cases)

Restore leg: `git checkout HEAD -- engine.ts`; blob equals the HEAD blob
`f5793bff919d`, `git diff HEAD` empty, porcelain clean; rebuilt; marker
absent
from all 14 built files. Pins restored: `250 passed (250)`, `6 passed
(6)`.
`engine.ts` is byte-identical on the final head (the later merge of
`main`
carried no `objectql` source).

## Verification on the final head `6752a29827` (merge of `origin/main`
at `1ca1eb0972`)

- `objectql` full suite: `367 files, 7384 passed`.
- `metadata-protocol` full suite: `206 passed, 3 skipped files; 3187
passed, 19 skipped`.
- `runtime` full suite: `317 files; 5176 passed, 19 skipped`.
- `service-analytics` full suite: `175 files; 4152 passed, 253 skipped`.
- `cli` unit tier: `252 files, 3685 passed`; integration tier, the three
files
  this branch or the merged `main` touched: `36 passed`.
- `mcp`: `35 files, 389 passed`; dogfood (`registry-gate-wiring` + the
two
files `main` added): `16 passed`; the `main`-added example, `metadata`
and
  `core` files: green.
- `plugin-security`, `plugin-approvals`, `service-settings`,
  `http-conformance`, `trigger-record-change` test tasks: turbo `38/38`
  (5 test tasks run, 33 cached builds).
- Build closure for the above: `63/63` turbo tasks.
- Before the merge (head `bab0903840`): typecheck of every touched
package
`76/76` tasks; `rest` repo project `177 passed`; the 14 `rest` harness
files
  `552 passed, 21 skipped`.
- CI on `6752a29827`: every check green except "Part-of PR must not also
close
  its card", which read the earlier body; this body is its input.

## Acceptance notes

- Recorded decision of card 3770 ("the engine deliberately does not
reject;
internal callers unaffected") is narrowed at the engine: the door's gate
is
unchanged and the engine now gives the same answer. ADR-0053's
type-blind
lowering is kept for a registered object with no field map, and removed
for an
  unregistered name (the bypass itself). No ADR text is edited here.
- Changesets: `@objectstack/core` minor (`Clause-②: yes`, the new
export);
`@objectstack/objectql` minor (`Clause-②: no (narrowing)`, with its
ADR-0087
disposition); `@objectstack/metadata-protocol` patch;
`@objectstack/spec`
  patch (docblock).
- `packages/cli/test/refusal-renders-once.e2e.test.ts` (added on `main`)
sits
in neither of the cli package's two vitest projects and was not run
here;
it drives refusal rendering of `os init` / `os compile`, which this
change
  does not reach.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants