Repository navigation
fix(runtime): a sandboxed body or an action handler reading the stored-metadata tables is served the data door's form (#21454) - #21513
Conversation
…-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>
…text seams 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>
… 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>
📓 Docs Drift CheckThis PR changes 2 package(s): 14 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 — 31 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 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
|
…inor under Clause-② yes Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #21454 (body and all 11 comments, rulings ① Derived judgments
② Semver level
③ Boundary flagsDev report
Escalated, unanswered on the card:
Implemented-by: VERDICT: PASS Generated by Claude Code |
…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>
…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>
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, notFixes: 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:@objectstack/spec/kernel;serveStoredMetadataReadserves one read.serveStoredMetadataReadsThroughserves a scoped API:find,findOneandaggregateon a family object, through every context the API can derive (sudo(),withRunAs(...), atransaction(...)callback's context and the contextbeginTransaction()returns). It is idempotent, so a hash is never keyed twice. Writes,countand every other object pass through unchanged.sandbox/body-runner.ts,buildSandboxApi. Both body faces (hook and action) get theirctx.apihere, 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, thectx.apian action body and a host code handler both receive, andbuildActionEngineFacade'sfind. Both action doors (REST/actionsand MCPrun_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 routesfindOnethrough 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
ctx.api.object(...)ctx.api.transaction(...)PERMISSION_DENIED/metactx.engine.findctx.api.object(...)PERMISSION_DENIEDFor every row marked "after", the pins assert four things:
Tests
packages/runtime/src/stored-metadata-reader-contexts.pin.test.tsis step 1's probe with its context cases flipped from RECORD to ASSERT. It boots thebootStackplugin set, in order, once inbeforeAll(never inside a case) and runs 15 cases.packages/runtime/src/stored-metadata-reader-seam.test.tsholds 8 unit cases for the routes around the seam that the composition does not exercise one by one. Both files are green at532c96d70f(15/15, 8/8).532c96d70f,pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2answered: Test Files 311 passed (311), Tests 4393 passed, 19 skipped.--project repoat265f1c1f32: Test Files 3 passed, Tests 751 passed.265f1c1f32,pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2answered: 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.532c96d70f,pnpm --filter @objectstack/runtime typecheck(tsc --noEmitpluscheck:test-typecheck, OK, ledger unchanged) andpnpm --filter @objectstack/metadata-protocol typecheckwere both green.@objectstack/runtimeload, and both builds of@objectstack/metadata-protocolexport the four functions.Reverse verification (forms only)
The fix was committed first, at
265f1c1f32. Then:ctx.api, the engine facade'sfind) were replaced throughscripts/ablation-replace.mjs. Each anchor went from 1 to 0 and each blob changed. The runtime was rebuilt, andscripts/ablation-dist-preflight.mjsfound 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.git checkout HEAD --on the two paths. Both blobs equal HEAD,git diff HEADis empty and the porcelain is clean. After a rebuild, the preflight with--absentwas clean and the pins answered 23/23 green.buildActionApi's malformed-envelope fallback is the one seam call no case exercises, so it was not mutated.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackwas derived with no paths at532c96d70fand gave 72 lines. All 72 were run at that head, each exited 0, and--ranreconciled them: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loadsanswered 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-contractasked for the new pinned row above.check:slot-lookupasked for the pins' handler plugin to read the engine slot through its contract type. Both are fixed in this PR.pnpm lint(the repo-wideeslint .) was not run locally; CI runs it.--format jsonat532c96d70f: 6 files, 0 errors, 0 warnings.eslint.config.mjsenables no type-aware linting (noparserOptions.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)
service-automationtier, 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.@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 ispackages/spec.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.OBJECT_NOT_FOUND), and the in-process verbs do not.ctx.api, and this PR does not project it, because a hook's input is also its write-back channel.Acceptance notes
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/21454-reader-context-family-serve.md, patch for@objectstack/runtimeand@objectstack/metadata-protocol.Generated by Claude Code