Repository navigation
fix(runtime)!: an app-authored body may not read the stored-metadata tables; it reaches them through the metadata API only (#21594) - #21660
Conversation
…tables (wip) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
… every door (wip) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ngine's own predicate (wip) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c145b6f09c9bd19b4514eaf2bf6ba188c53bf492 && git checkout c145b6f09c9bd19b4514eaf2bf6ba188c53bf492
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 100f68b77fec265f0cbfd7d854f6f0d2d557f406 f5d575d07290931aa3c648cf8932de5d7c83b4b5 && git checkout -B drift-repro 100f68b77fec265f0cbfd7d854f6f0d2d557f406 && git merge --no-ff f5d575d07290931aa3c648cf8932de5d7c83b4b5
node scripts/docs-audit/affected-docs.mjs --json 100f68b77fec265f0cbfd7d854f6f0d2d557f406
|
Contract reviewServed-tier: PR #21660 (card #21594, ruling Gates on the head. Every check-run is ① Derived judgmentsEach accept-set and public-surface change the diff implies, judged against the head's source (the seam,
Nothing judged WRONG. ② Semver level
③ Boundary flagsEscalated (to the maintainer, through the seat); not a verdict item:
Held by the seat as a landing precondition (noted, not measured here):
Dev flags, each answered:
Out-of-scope findings, carriers as the dev and the seat named them:
Implemented-by: VERDICT: PASS Generated by Claude Code |
…ts subject record (wip) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ith host and ordinary controls (wip) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Contract reviewServed-tier: PR #21660 (card #21594; ruling B What moved since the PASS at Gates on the head. Every check-run is ① Derived judgmentsRound 1's twelve items were re-read on this head and stand: the body read layer beneath the unchanged write layer in
Nothing judged WRONG. ② Semver level
③ Boundary flagsThe earlier review's flags, each closed:
Dev flags (round 2), each answered:
Out-of-scope findings, carriers as named:
Implemented-by: VERDICT: PASS Generated by Claude Code |
…tead of a tracker number (stage 11) (objectstack-ai#21736) Part of objectstack-ai#20749 Clause-②: no Stage 11 of this card, and the second area of class (e): the test strings shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513. This stage takes the whole `kernel/` directory. Its 102 test-title and test-message literals carried 106 tracker ids citing 69 records. Each id now either states what its record decided, in words (form D), or is dropped where the title already says it. Text only: no assertion, fixture value, test count or code comment changes. ## Census at the base (`3fa850cf00`, the claim's base) Instrument: stage 10's `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`) and stage 9's `census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), both byte-identical to the copies stage 10 used. A literal counts as a test title when its folded message is argument 0 of a `describe` / `it` / `test` call, `.each` / `.skip` / `.only` chains included. Everything else is an "other" string. Both instruments read **1696 messages / 1807 ids in 405 files at the base**, which is stage 10's reading at its head exactly. `kernel/` reads 102 / 106, also stage 10's figure. | directory | files | messages / ids | titles | other | |:--|--:|--:|--:|--:| | `data/` | 95 | 468 / 501 | 445 / 475 | 23 / 26 | | `ui/` | 81 | 392 / 415 | 374 / 397 | 18 / 18 | | `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 | | `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 | | (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 | | **`kernel/`** (this PR) | 36 | **102 / 106** | 92 / 96 | 10 / 10 | | `shared/` | 21 | 85 / 95 | 73 / 81 | 12 / 14 | | `contracts/` | 25 | 63 / 74 | 59 / 70 | 4 / 4 | | `conversions/` | 9 | 34 / 34 | 34 / 34 | 0 | | `security/` | 8 | 28 / 28 | 28 / 28 | 0 | | `ai/` | 9 | 18 / 20 | 13 / 15 | 5 / 5 | | `identity/` | 6 | 15 / 15 | 14 / 14 | 1 / 1 | | `integration/` | 4 | 14 / 14 | 13 / 13 | 1 / 1 | | `migrations/` | 2 | 9 / 12 | 9 / 12 | 0 | | `marketplace/`, `meta-spelling/`, `studio/` | 5 | 7 / 7 | 7 / 7 | 0 | | **total** | **405** | **1696 / 1807** | **1587 / 1692** | **109 / 115** | - **Controls.** Lit, a title: `kernel/capability-metadata-kind.test.ts:66` reads one message with objectstack-ai#5961. Lit, a template-literal expect message: `kernel/cli-command-contribution-retirement.test.ts:74` reads one message. Dark: the `// ─── [objectstack-ai#17178] …` comment at `kernel/execution-context.test.ts:205` reads 0 (the file's messages sit at `:72`, `:167`, `:219`, `:233` and `:237`). Planted in a scratch copy: an id added to a title reads 1 / 1, and an id in an added comment reads 0. - **A wider pattern** (any `#` plus digits) reads 107 / 111 under `kernel/` at the base. The five extra hits are hex colours (`#94A3B8`, `#0f0`) in `functional-completeness.test.ts` and one `§3.10 objectstack-ai#3` section reference in `manifest.zod.ts`, a non-test file. None is a tracker id. At the head the wider pattern reads only those five, and the gate pattern reads 0 / 0. - **At the head:** 1594 messages / 1701 ids in 369 files. `kernel/` reads 0 / 0. Nothing else moved. ## How the area was chosen Stage 10's rule, applied before any card was read: rank whole first-level directories by ids, and take the busiest one within about 10% of the ~100-id bound. The four busiest each exceed the bound alone: `data/` (501), `ui/` (415), `api/` (201) and `system/` (165). The files directly in `src/` (120) are 20% over. `kernel/` (106) is the busiest whole directory within the bound, and its census reads exactly stage 10's 106, so the rule needed no second pass. **Named for the next stages:** `data/` (about five stages, by subdirectory or file group; `data/driver/` alone is 52), `ui/` (about four), `api/` (two), `system/` (two), the files directly in `src/` (one, 120), `shared/` (one, 95), `contracts/` with `conversions/` (one, 108), and `security/`, `ai/`, `identity/`, `integration/`, `migrations/`, `marketplace/`, `meta-spelling/` and `studio/` together (one, 96). ## What each id became Of the 106 ids, 32 now state a decision in words, in 31 literals. 74 are dropped where the title already explains them; two of those (objectstack-ai#14478) sit in literals that also gained words for another id. Every record was read with its comments through REST. 58 answer 200. Ten answer 404, and their decisions were read from what landed. One is in a repository not attached to this session. | record | ids | result | |:--|--:|:--| | objectstack-ai#12007, objectstack-ai#11825, objectstack-ai#12340, objectstack-ai#4914, objectstack-ai#15932, objectstack-ai#11846, objectstack-ai#16059 | 10 of 20 | Every expect message that read "must have zero holders after #N" or "must not be exported after #N" now reads "after its retirement": each record retired the names it lists. The other 10 sit in `describe` / `it` titles that already say what was retired, and were dropped. objectstack-ai#11846 answers 404; its retirement was read from the CHANGELOG entry for landing `0c2334f`. | | objectstack-ai#7280 | 1 | "the ADR-0069 gate posture is a declared field": `authGate` is declared on `ExecutionContextSchema`, not spread behind an `as any`. | | objectstack-ai#17178 | 1 | "SEED_WRITE_EXECUTION_CONTEXT — one spelling of the seed posture": one exported constant replaced the private copies. | | cloud#687 | 1 | "(the founding case: a roll-up that reads 0 forever)". The cloud repository is not attached to this session (403). The decision was read from ADR-0078's "Surfaced by" line and the CHANGELOG paragraph on the founding case: a bare `{ type: 'summary' }` field read 0 forever, and the rule now flags it as an error. | | objectstack-ai#14192 (404) | 4 | "(the silent-drop measurement, inverted)", where the title said "the card's measurement". Dropped from 3 titles that state the refusal. Decision read from landing `4d0d944`: `ManifestSchema` goes strict and refuses unknown keys inside `manifest:`. | | objectstack-ai#10726 (404) | 2 | "removed for the `http.server` mount, maintainer-ruled 2026-08-22", where the title said "Option B". Read from landing `bc56e18` and PR objectstack-ai#12417's body: Option B removes `contributes.routes` and points authors at the imperative `http.server` mount. Dropped once. | | objectstack-ai#4148 | 1 | "the object/field unknown-key warnings survive the generalization", where the title said "the objectstack-ai#4148 behaviours". | | objectstack-ai#4001 | 3 | "(the evidence base for the strict tiers)", where the title said "objectstack-ai#4001 evidence phase". Dropped from 2 titles that state the pinned rule. | | objectstack-ai#4167, objectstack-ai#8687 | 3 | "top-level stack keys (named, then refused at parse)": objectstack-ai#4167 made an undeclared top-level key say so instead of vanishing, and objectstack-ai#8687 ruled Shape B, a strict top level. Dropped once more for objectstack-ai#8687. | | objectstack-ai#15624 | 2 | "cache.ttl → deleted (the unread outer block is retired)". Dropped once. | | objectstack-ai#14478 | 3 | Dropped. The `→ ttlMs` / `→ timeoutMs` renames and "carry their unit" already state the rule: the unit lives in the key name. | | objectstack-ai#15939 | 1 | "RuntimeConfig.resourceLimits.timeout → timeoutMs (its unit was named in JSDoc only)", where the title said "ruling A". Ruling A renames the keys whose unit was named only in JSDoc, per file. | | objectstack-ai#5086 | 2 | "(PUT /meta refuses the inlet)": a code-only kind's create is refused with 403 `NOT_CREATABLE` before anything persists. | | objectstack-ai#7743 | 1 | "UNCHANGED, the field overlay refusal stays", where the title said "objectstack-ai#7743's overlay refusal". | | objectstack-ai#8154 | 1 | "(the consumer contract of the per-type redaction hook)". | | objectstack-ai#21120 | 1 | "stored metadata ROWS — the family-wide seam every exit routes through". This says only what the card's public summary says: one shared seam, which every surface routes through or refuses. | | objectstack-ai#6245 | 1 | "the bound-but-unregistered fence, pinned": schemas are bound for those kinds WITHOUT registering them. | | objectstack-ai#11263 | 1 | "PLATFORM_PLUGIN_WIRED_RUNTIMES — runtimes wired by plugins[], not by a token": a sibling roster was added, and no token was minted. | | objectstack-ai#3366 | 1 | "classifyRequiredCapability — preflight for an installable provider in this edition". | | objectstack-ai#17676 | 1 | "package-registry carve-out — its persistence is always-on core, split from `marketplace`", where the title said "ruling A′". | | objectstack-ai#16365 | 1 | "accepts %s, which the regex refused before the SemVer widening", where the title said "the pre-objectstack-ai#16365 regex". The `%s` values do not change. | | objectstack-ai#17227 | 1 | "dashboard.header.actions stays titled — the first carrier given item-level names". | | dropped only (live) | 45 | objectstack-ai#3308 (2), objectstack-ai#3433, objectstack-ai#3760, objectstack-ai#3786, objectstack-ai#4212, objectstack-ai#4509, objectstack-ai#4587, objectstack-ai#4657, objectstack-ai#4741, objectstack-ai#4834, objectstack-ai#4939, objectstack-ai#5488, objectstack-ai#5961, objectstack-ai#6881, objectstack-ai#6931, objectstack-ai#7893, objectstack-ai#8586, objectstack-ai#10039 (2), objectstack-ai#11169, objectstack-ai#12032, objectstack-ai#12428, objectstack-ai#13613, objectstack-ai#15678 (7), objectstack-ai#16328, objectstack-ai#16334, objectstack-ai#16449, objectstack-ai#17232 (2), objectstack-ai#17445, objectstack-ai#17780, objectstack-ai#18124 (2), objectstack-ai#18791 (3), objectstack-ai#19630, objectstack-ai#20102: each title already states the pinned decision. | | dropped only (404) | 8 | objectstack-ai#10194 (landing `2306a76`: each bound entry is its stack collection's schema), objectstack-ai#10338 (`d2619fd`: `target` optional, the gate holds the flow requirement), objectstack-ai#10724 (`be21955`: the nine dead members tombstoned), objectstack-ai#11330 (`a9ee98992`: the trust-tier text tells the truth), objectstack-ai#11332 (`dce5cd4`: the three dead containers retired), objectstack-ai#13135 (`9e0ba21`: the paper customization protocol retired), objectstack-ai#17147 (2, `aaacf1d5c`: the granted set is registered and refuses nothing, said truthfully). Each title already carries what landed. | ## Readers - **Test-name filters:** none. A tracked-tree search for `-t` and `--testNamePattern` finds only `packages/qa/dogfood/README.md:142` (`-t "owner-scoped"`), which is unrelated. - **Snapshots:** none. `kernel/` has no `__snapshots__`, and no `.snap` file is tracked under `packages/spec`. - **Titles by substring:** every old title, plus a window around each id (256 needles), was searched across the tracked tree outside its own file. No gate, doc or script matches one. At the head, 8 hits remain: three sibling titles in other lanes' or stages' files (`metadata-protocol/src/protocol.capability-write-door.test.ts:183`, `spec/src/api/contract.test.ts:813`, `spec/src/system/auth-config.test.ts:520`), three released `packages/spec/CHANGELOG.md` entries, and one code comment in `kernel/metadata-authoring-lint.ts:268`, which belongs to the comment lane. ## Text-only proof Stage 10's scratch tool (`textonly10.cjs`, md5 `d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file on three legs: 1. **Skeleton:** the full AST, with string pieces masked. It must be identical. 2. **Comments:** every comment, byte-equal. 3. **Strings:** each string leaf that changed must sit in a test-call title position, or on one of the 10 declared lines. Those are the expect messages at `cli-command-contribution-retirement.test.ts:74` and `:86`, `plugin-lifecycle-advanced-retirement.test.ts:103`, `:124` and `:141`, `plugin-loading-retirement.test.ts:125`, `plugin-security-scan-result-retirement.test.ts:123`, `preview-mode-retirement.test.ts:193` and `startup-orchestrator-retirement.test.ts:85` and `:106`. Each changed leaf must carry a tracker id before and no `#` plus digits after. - **Result:** 36 of 36 files SAME, 102 changed (92 title, 10 declared), on all three legs. - **Diff hunks:** exactly the 102 planned lines, with every file keeping its line count. - **Controls (10 of 10 as predicted, on scratch copies, each anchor hit once):** identifier rename DIFF; numeric literal DIFF; comment edit COMMENT DIFF; a non-title string with an id VIOLATION; a rewritten title given a new id VIOLATION; a title that was id-free at base edited VIOLATION; one title reverted to base SAME; a declared string keeping an id VIOLATION; an undeclared expect message changed VIOLATION; a title re-split into a `+` chain DIFF. **Test counts:** the 36 files were run at the base (in a separate base worktree) and at the head: 841 / 841 tests on both sides, with the same count and status sequence per file in 36 of 36. 388 full test names change, and each equals the base name with the planned replacements applied. ## Changeset: `skip-changeset` Measured, not assumed: - `npm pack --dry-run` of `@objectstack/spec` lists 2068 files under `files[]`. 0 of the 36 touched files are in it, and 0 `*.test.ts` at all. The controls `src/kernel/manifest.zod.ts` and `src/kernel/execution-context.zod.ts` are in it. - In `dist/`, three new phrases and three old ones each read in 0 files. The control `Plugin compatibility ranges (ADR-0025` reads in 20. So this PR publishes nothing, and no changeset is added. ## Verification (at `e0ad8f50af`) - `pnpm turbo run build` over all packages: 71 / 71 (at the first commit), then `@objectstack/spec` rebuilt at `e0ad8f50af`. - `@objectstack/spec`: `vitest run --project local`, 613 files and 18215 passed, 1 todo. `typecheck` exit 0, including `check:test-typecheck`, whose program holds all 36 touched files. - **Gates:** `dispatch-gates --commands` derived 79 families at `e0ad8f50af`, and all 79 exit 0. `--ran` reconciles: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN. - **ESLint, a proven narrowing:** `--no-inline-config` over the 36 files, 0 errors and 0 warnings. The population comes from ESLint's own config: 36 configured, 0 ignored. No `parserOptions.project` or `projectService`, so no untouched file's verdict can move. - `check-governed-merges --test`: NOT governed, 204 changed lines. ## Acceptance notes - **Code comments still carry ids** in these 36 files and in the `kernel/` sources, for example `execution-context.test.ts:205`, `metadata-authoring-lint.ts:268` and `functional-completeness.ts:148`. They are the comment lane's, untouched here. - **A sibling title in another package** repeats `objectstack-ai#5961 — capability` (`packages/metadata-protocol/src/protocol.capability-write-door.test.ts:183`). It is that package's test-string stage, not this one. - **The second commit** (`e0ad8f50af`) rewords one title from this PR's first commit, "the one carrier already titled", which read as a tautology, to "the first carrier given item-level names". Every proof above was re-run at that head. - **`origin/main` moved** three commits past the base before this PR opened (objectstack-ai#21717, objectstack-ai#21715, objectstack-ai#21660). None touches `packages/spec`, so nothing was merged. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21594
Clause-②: yes (narrowing)
An app-authored body (a sandboxed hook, action or job body) may no longer read the stored-metadata tables (
sys_metadata,sys_metadata_history). Every read verb answers the body boundary'sPERMISSION_DENIED/ 403 before it runs, with a prescription naming the metadata API's read route. With the binding and write refusals already landed, an app-authored body now reaches these tables through the metadata API only. This is the maintainer's ruling, letter B (record5974479930).An action body is not handed a family row either. The
/actionsdoor loads an action's subject record before it dispatches. When that record is a family row, the call now answers the same 403 before the body runs, instead of handing the body the row asctx.record. That covers an action declared on a family table and an object-less action addressed under one. The contract review's flag 2 raised this path; it was measured first, at the doors (below).The handler contexts (A2) are ruled A by the maintainer (record
5978653398, 「同意」 on director batch #276). Ruling B covers sandboxed app bodies only; app host handlers registered withregisterActionkeep the projected read. This PR leaves them exactly as they were: theirctx.api,ctx.engine.findand subject record are unchanged. C (a host-code trust model at the engine entry) is not taken and not filed.The census's cloud leg is answered. The same ruling record cites triage's reading
5976321402: zero app-authored readers of the family inobjectstack-ai/cloud, neither bodies nor host handlers. That reading is triage's, not this container's.What changed
packages/runtime/src/stored-metadata-body-boundary.ts. AddsstoredMetadataBodyReadRefusal(object, verb). It uses the write refusal's own envelope (STORED_METADATA_BODY_BOUNDARY_CODE/_STATUS,PERMISSION_DENIED/ 403), so there is no new error code. Its prescription names the read route:GET /api/v1/meta/:type/:name, and.../historyfor versions. The binding and write refusals' text is byte-unchanged; the shared constructor takes the prescription as a defaulted parameter.packages/runtime/src/stored-metadata-reader-seam.ts. Adds a body READ layer,refuseStoredMetadataBodyReads, on the same derive walk as the write layer:object(),sudo(),withRunAs(), atransaction(fn)callback andbeginTransaction(). It refuses exactly the read verbs the seam serves (find,findOne,count,aggregate), before the verb runs and before its query is looked at. So a refused read gives the same answer whatever its filter, sort, grouping, search or projection names: no rows, and no oracle. The write layer (refuseStoredMetadataBodyWrites) is unchanged in code. It sits over the read layer and passes reads down to it; only its comments now say so.packages/runtime/src/sandbox/body-runner.ts.buildSandboxApiis the one place every body face gets its API. It is nowrefuseStoredMetadataBodyWrites(refuseStoredMetadataBodyReads(source)).stored-metadata-body-boundary.tsaddsstoredMetadataBodySubjectRecordRefusal(object, action). It uses the same envelope (PERMISSION_DENIED/ 403, operationrecord) and the same read prescription.sandbox/body-runner.tsconsults it first inactionBodyRunnerFactory's bound handler, before the sandbox context is built and before the body runs. That handler is the one point every action body passes through to run, whichever door bound it, and the one place the handler is known to be a body. The subject is the door-stamped routed object (params.objectName, which both action doors write after the caller's params), else the declared object. A record is handed when the call carries a record id or a non-empty record. A family-routed call with neither hands the body nothing and runs.serveStoredMetadataReadsThroughno longer wraps a body's API inbuildSandboxApi. With every family read and write refused first, it served a body nothing. It is removed, not kept beside the refusal. Nothing else became dead: the seam's projection, evaluate refusals, default-search narrowing and write-return serve still serve the host-handler contexts (ruling A keeps them). No exported symbol is deleted or renamed.packages/runtime/src/action-execution.ts. Comment only (buildActionApi): an action body's API is built over this context, and the body layers refuse first..changeset/21594-body-family-read-refusal.md.@objectstack/runtimeminor, BREAKING (narrowing), exactly one ADR-0087 marker (not-required (no-migration-prescription)), in the shape of the evaluate-refusal changeset. It states the route, and which earlier entries of this release it supersedes for bodies. This round adds a Subject record bullet, and names a host handler's subject record among the unchanged.scripts/engine-double-contract.pinned.json. One row for the new unit pin's double, whosefindOneroutes through the engine's own predicate (check-engine-double-contract.mjs --write).Measured first
A1. Census of app-authored readers (the stop condition): 0 readers
examples/**at15fe567c9c:app-showcasechangelog (2) and one code comment inapp-showcase/src/system/connectors/index.ts.SystemObjectName.METADATA,STORED_METADATA_BODY_OBJECTSandisStoredMetadataBodyObjecthave 0 hits. A non-literal.object(...)argument has 0 hits..object(...)targets: fourshowcase_*objects.bind-position-sets.ts×2,seed-approval-demo.ts). Each is called only with non-family objects.body:key; 5 touchctx.api,ctx.engineorregisterAction.4054ec2680(a public shallow clone, read only, 924 tracked files):src/**orapps/**..object(...)reads: 7, in hook bodies. Each is bound to a local list ofcrm_*objects or to the hook's own object.crm_*or non-familysys_*objects.registerActionappears in a comment only.objectstack-ai/cloud): NOT MEASURED from this container (private, unreachable). Answered by triage's reading5976321402, as the ruling record5978653398cites: zero app-authored readers.Flag 2 (this round). The subject record: can a body be handed a family row?
Measured at the doors on the head before the fix (
d5b890226c), with a temporary probe that was not committed. Each body only returned what it was handed asctx.record; the probe recorded classes (keys present, body column type, hash form), never values.defineStack/os validate. A strictdefineStack(the default) refuses an action whoseobjectNameis a family table:STACK_CROSS_REFERENCE_INVALID, because the table is not an object the stack defines. That is a generic cross-reference check, not the family boundary.defineStack(…, { strict: false })accepts it:os validateanswersvalid: true, exit 0, with placement warnings only. An object-less body action is accepted in both modes. Measured withbin/run-dev.js validate --json.POST /api/v1/marketplace/install-local). The package with a family-bound action and an object-less body action installs (200,success: true). It binds both handlers,sys_metadata:…andglobal:…, through the runtime's one binder. Handed a door-shaped context carrying a family row, each body received it. Measured in@objectstack/cloud-connectionagainst the built runtime./metaaction save door.PUT /meta/action/:namewithobjectNameset to a family table answers 200. Once bound,POST /actions/sys_metadata/:name/:idhanded its body the row.AppPluginover a JSON bundle, the compositionos start --artifactbuilds). Both actions bind./actions, as the administrator. The body received the door-served family row on both tables: the body column as its projection and the hash in keyed form, with no stored credential. That held for an action declared on the table, for an object-less body action addressed under it, and for the/meta-declared action. No family declaration is needed for the object-less route; any installed object-less body action can be addressed under a family table. Reachable./actions, as a member.404 RECORD_NOT_FOUND: the door's own subject load, under the caller's read scope, stops it, and the body never runs.run_action. Not reached. The door refuses an action on anysys_*object before the record load, and resolves an object-less action only under its own key. The composed kernel's metadata service lists no standalone action, so this door is pinned at unit level.Reachable, so it is refused in this card. The seam is the action body's own handler, not the door: the doors dispatch body and host handlers alike and cannot tell them apart at the prefetch. A binding-time refusal, the parallel of the hook-binding refusal, would not see the object-less route, so it is not the only coherent seam; it would also be insufficient. After the fix, on
f5d575d072, the same probe gave:/actions, administrator:403 PERMISSION_DENIEDnaming the metadata API, for every family case (both tables, the family-bound action, the object-less route, the/meta-declared action).404at the door.PERMISSION_DENIED/ 403.A2. Which seam contexts carry app-authored code
buildSandboxApi)./actions, MCPrun_actionand an engineexecute; job bodies on the job scheduler.ctx.engine.findand ③ctx.apiof an action handler (buildActionEngineFacade,buildActionApi)./actions(domains/actions.ts) and MCPrun_action(action-execution.ts).packages/**, the onlyregisterActioncallers are the two body runners (the objectql metadata-service bind andapp-artifact-handlers.ts). Their handlers are sandboxed bodies, which never see ② and get ③ only as the source the body layers wrap.examples/app-todoregisters 8 host handlers from itsonEnable(ctx)throughctx.ql; hotcrm registers none.5978653398). Left served, unchanged.packages/**reads through the engine or a driver directly. That covers the metadata protocol, the objectql plugin, the core translation fallback, the flow credential channel and the CLI. None reads through a body API or a handler context. Pinned unaffected:A3. The envelope
PERMISSION_DENIED/ 403, withobjectandoperationset. There is no new error code.find,findOne,count,aggregate). Search is a query shape on a read, and is refused with it.A4. The deletion and the ledgers
refuseStoredMetadataBodyReads,storedMetadataBodyReadRefusal); neither is on the package entry.packages/spec/liveness/**, every*.ledger.*file,scripts/engine-double-contract.pinned.json,content/docsanddocs. One hit: a liveness note inhook.jsonthat citesbuildSandboxApireadingctx.apifrom the engine context. That is still true, and the function keeps its name.packages/metadata-protocol: no edit. No symbol there is left without a consumer. The seam still consumes each one for the handler contexts, as counted in the report.git grepover hand-writtencontent/docsand publishedskills/found no page saying a body can read the family's tables. Zero hits, so nothing was touched.A5. Reverse verification (ablation)
scripts/ablation-replace.mjsin WRAP mode onpackages/runtime/src/stored-metadata-reader-seam.ts, from the committed state (9c87884191).if (BODY_FAMILY_READS.has(prop)) {, hit ×1 → ×0. Replaced byif (false && BODY_FAMILY_READS.has(prop)) {, ×0 → ×1.76eabe5afe1a→c56dca3ae3e6.stored-metadata-body-writes.test.ts,stored-metadata-body-boundary.test.ts,stored-metadata-body-boundary.pin.test.ts), the reader-seam unit file, the handler ② / ③ cases and the platform-reader controls.76eabe5afe1a),git diff HEADempty,git status --porcelainempty.packages/runtime/src/sandbox/body-runner.tsat committed5e8fd37d78. Anchorif (subjectRefusal) throw subjectRefusal;, hit ×1 → ×0. Replaced byif (false && subjectRefusal) throw subjectRefusal;, ×0 → ×1.522b737bc800→3eb2d928aba3./meta-declared action).522b737bc800),git diff HEADempty,git status --porcelainempty.The handler contexts (A2): ruled A
Ruling
5978653398, letter A (maintainer 「同意」 on director batch #276): ruling B covers sandboxed app bodies only, and app host handlers registered withregisterActionkeep the projected read. The record's stated cost: the reader-context seam's projection and narrowing code stays, for host handlers. The analysis that went to the maintainer is kept below.Question. Should the read refusal also reach an action handler's
ctx.apiandctx.engine.find, the host code an app registers withregisterAction? Or do those stay outside, the same context set as the write refusal?ctx.qlinonEnable. A refusal on itsctx.apiwould declare a boundary the runtime cannot enforce.Tests
All at the final head
f5d575d072: the merge oforigin/mainat100f68b77finto this branch, as a merge commit, built whole (pnpm build --concurrency=2, 72 of 72 tasks, none cached).src/stored-metadata-body-reads.test.ts, 26/26. The first round's 15 cases are unchanged. This round adds:src/stored-metadata-reader-contexts.pin.test.ts(REST/actions, the data door,/meta), on the built packages, 27/27. This round adds six cases:/meta-declared family-bound action, refused once bound;404 RECORD_NOT_FOUND;@objectstack/runtimesuite:--project local322 files, 4591 passed and 19 skipped;--project repo3 files, 751 passed. The two runs were joined with&&under one lock verdict, command-exit 0.pnpm --filter @objectstack/runtime typecheckpasses,check:test-typecheckOK.pnpm lintexits 0 with no findings.pnpm --filter @objectstack/spec exec vitest run --project repo scripts/liveness/evidence.test.ts, 42/42.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackgives 71 families against merge base100f68b77, all exit 0, withcheck:dual-build-cjs-loadsafter the full build. Reconciled with--ran: "71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN", every exit code recorded.Acceptance notes
packages/spec/src/kernel/stored-metadata-body-objects.tslists what the runtime refuses for a body as binding and writing; it does not mention reading. That file is outside this lane's fence (packages/spec). Carrier: none.origin/mainat100f68b77fwas merged into the branch as a merge commit (f5d575d072), with no rebase and no force-push. It merged cleanly. Main's change tosandbox/body-runner.ts(the job face's organization envelope) still builds its API throughbuildSandboxApi, so the job face keeps both body layers./metadoor and a laxdefineStackall accept an action declared on a family table, and the body runner binds it. The refusal lands at run time, when a family row would be handed over. A binding-time refusal (the parallel of the hook-binding refusal) was not added: it could not see the object-less route, which needs no family declaration, and the run-time refusal covers both. Whether binding should also refuse is not this card's question. Carrier: none.Generated by Claude Code