Commit bd70706
Fixes #21520
Clause-②: yes (narrowing)
Executes ruling A (record `5965059068`, maintainer 「同意」): **an
app-authored body may not touch the stored-metadata family's tables**
(`sys_metadata`, `sys_metadata_history`). For an app-authored body the
metadata protocol is their only writer. Two refusals, each carrying
`PERMISSION_DENIED` / 403 and a prescription that names the metadata
API:
1. **Binding.** A hook with a sandboxed `body` whose `object` names a
family table is refused at registration.
2. **Writing.** A sandboxed body's write of a family table through
`ctx.api` is refused before the write runs. This also closes the write
verb's own predicate path, which triage `5965718076` carried onto this
card: a refused write runs nothing, and its answer does not depend on
what it names.
Platform code is outside the refusals: the metadata protocol and its
writers, the platform's code hooks, and host code a deployer registers.
## Census of platform writers (A1), by symbol walk
**Method.** A TypeScript AST walk (the compiler API) over every non-test
source file under `packages/*/src`: 2707 files at `fd5a1cd597`.
- A call counts as a family write when its callee is a write verb
(`insert`, `create`, `update`, `updateById`, `upsert`, `delete`,
`deleteById`, `updateMany`, `deleteMany`, and the bulk spellings) and
its object argument resolves to a family name.
- An object argument resolves when it is a literal, a same-file binding,
or an exported constant (`OVERLAY_TABLE`, `METADATA_HISTORY_OBJECT`).
The receiver `X.object(name)` counts the same way.
- Write calls with a non-literal object argument, in files that name the
family, are listed separately so a dynamic writer is not hidden.
**Readings.**
- 13 resolved family writes: `metadata-protocol`
(`sys-metadata-repository.ts` ×5, `protocol.ts` ×2,
`migrations/recorded-by-sentinel.ts` ×1), `service-datasource`
(`datasource-admin-plugin.ts` ×4) and `plugin-security`
(`permission-set-overlay-discard.ts` ×1). Every one is platform module
code calling the engine directly (`this.engine` / `engine` / `ql`).
- 112 unresolved write calls across 18 family-naming files. All are
module code on an engine or driver receiver.
- `buildSandboxApi` is the only constructor of a body's API. It is
reached only from `buildSandboxContext` (hook bodies) and
`buildActionSandboxContext` (action bodies). No platform writer goes
through it.
- Zero shipped hook or action bodies name a family table in
`packages/**` or `examples/**` (non-test). The only `object:
'sys_metadata'` hits are the platform's own list views in
`metadata-core`, matching the ruling's census.
**A1's assumption measured false: the existing seam is not body-only.**
`serveStoredMetadataReadsThrough` is applied at `buildSandboxApi`
(bodies). It is also applied at `buildActionApi`, which is the `ctx.api`
of a host code action handler as well as of an action body. So a throw
in `serveRepository`'s shared write branch would also refuse deployer
host code. The ruling says the seam refuses bodies only, and triage
`5964836549` put deployer host code outside the family. So the write
refusal is a **separate, body-only layer in the same seam file**:
- `refuseStoredMetadataBodyWrites` layers over the read seam.
- It shares one derived-context walk (`deriveThroughSeam`) with the read
seam, so there is no second walk.
- It is applied only at `buildSandboxApi`.
`serveRepository`'s write branch keeps serving write returns for host
handlers. Its comment now says why the refusal is not attached there.
## The binding point (A2): one place every door shares
Every door a body hook binds through reaches `hookBodyRunnerFactory`'s
per-hook resolver. That resolver is where a `body` becomes a handler, at
registration:
- the boot artifact and an installed artifact, install and rehydrate,
through `bindAppArtifactHandlers` (its explicit runner);
- runtime-authored hooks, through ObjectQLPlugin's metadata-service bind
(boot sync and resync), which use the engine's default body runner
installed by `AppPlugin`. That runner is the same factory.
`bindAppArtifactHandlers` alone would have missed the runtime-authored
door. The refusal is a throw from the resolver, so the binder records it
against the hook and logs it at `error`, or rethrows under `strict`. The
hook is never registered.
**Wildcard.** A `'*'` body hook names no family table, so it binds, but
it admits the family's tables. Its body is therefore not run for a
family table's event: a dispatch-side check in the bound handler. The
bind says so once, at `info`.
**Platform hooks** are code handlers, never bodies, so they never reach
this factory. Pinned: a code hook on `sys_metadata` still binds and
fires, and the metadata door's save still fires a platform code hook.
## Codes (A3): an existing code fits, no new ledger row
Both refusals carry **`PERMISSION_DENIED` / 403**, a member of the
ledger's `ErrorCode` union (the standard catalog).
- The condition is generic: this author context is not permitted this
operation on this table.
- The ledger's admission rule (#8211, mechanical) sends a generic
permission condition to the standard member rather than to a registered
synonym.
- Precedent: the flow-authoring write gate refuses an authoring write by
authority with the same code.
- What the author does instead is carried in the prescription.
So this is **not** `PENDING LEDGER CODE`, and nothing under
`packages/spec` is edited.
## Reach first (A5), measured as classes before the fix
The pins below were run against the pre-fix `body-runner.ts` (BASE
`fd5a1cd597`, byte-restored to HEAD afterwards, `git diff HEAD` empty).
Every observation is a neutral marker token on a free-text column. No
stored content is read.
- **Binding (unit tier, real ObjectQL + QuickJS):** a body hook
targeting each family table bound and ran on that table's write event,
through all three doors (artifact binder, runtime-authored default
runner, wildcard). Result: 6 red, 2 controls green.
- **Composed kernel:**
- The metadata door's own save ran an explicit family body hook, the
wildcard body hook and a runtime-authored family body hook (all three
markers present on the saved row).
- An elevated action body's insert into `sys_metadata` answered `200`
for the administrator and for a member.
- Result: 4 red, 3 controls green.
## Pins
- `stored-metadata-body-boundary.test.ts`, 8 cases, binding, on a real
engine:
- refused at registration for each family table, string and list forms,
with code, status and the metadata-API prescription;
- refused and recorded through the artifact binder and through the
runtime-authored default runner;
- thrown under strict binding;
- a wildcard binds and never runs on a family table;
- controls: an ordinary hook binds, and a code hook on a family table
binds.
- `stored-metadata-body-writes.test.ts`, 10 cases, writing, on a
counting double:
- every write verb on each family table is refused before it reaches the
store;
- a predicate write answers identically whatever its predicate names,
and runs nothing;
- reads pass to the read seam;
- controls: ordinary tables write;
- `sudo`, `withRunAs`, `transaction` and `beginTransaction` refuse the
same way;
- the layer is idempotent and transparent to the read seam's marker;
- the read seam alone (a host handler's `ctx.api`) keeps its writes;
- through the real QuickJS sandbox, an action body and a hook body on an
ordinary table are both refused.
- `stored-metadata-body-boundary.pin.test.ts`, 7 cases, composed kernel,
boot paid in `beforeAll`:
- the metadata door's save runs no body bound to a family table, and
still fires the platform code hook;
- controls: ordinary-table hooks fire, the wildcard among them;
- a runtime-authored family hook is not bound, while a runtime-authored
ordinary hook binds;
- an action body's family writes (an insert, a predicate update, a
history insert) answer `403 PERMISSION_DENIED` and land nothing, for
administrator and member;
- control: the same body's ordinary write lands.
## Reverse verification (ablation), fix committed first, at `0d8af06c80`
Each leg ran through `scripts/ablation-replace.mjs`: the anchor hit 1 →
0 on disk, the blob changed, and the restore was proven (blob == HEAD,
`git diff HEAD` empty). The pins resolve `body-runner.ts` by relative
path within the package (src), so no dist leg applies.
- **A1**, registration throw disabled: 5 red (the 5 refusal and record
pins), 20 green. The composed "no body ran" pin stayed green because the
dispatch-side check still stops the body: defence in depth, observed as
expected.
- **A2**, dispatch-side check disabled: 2 red (the wildcard unit pin,
and the composed save pin with the wildcard marker present).
- **B**, the body write layer removed from `buildSandboxApi`: 4 red
(both sandbox unit pins, and both composed write pins).
## Tests and gates, at `9a95e459d1` (after merging `origin/main`
`ce532184d1`, which carries #21539's landed seam)
- `@objectstack/runtime`, `--project local`: Test Files 316 passed,
Tests 4444 passed, 19 skipped. `--project repo`: 3 files, 751 passed.
- `pnpm --filter @objectstack/runtime typecheck`: exit 0.
`check:test-typecheck` is OK with the ledger unchanged, and all three
new test files are in the `tsconfig.test.json` program
(`--listFilesOnly`).
- `dispatch-gates --commands --repo objectstack-ai/objectstack` with no
paths derived 62 families. All 62 were run, each exit 0, and `--ran`
reconciled 62 derived, 62 run, 0 not-measured.
`check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET; after
a full `turbo run build` it measured 106 entries across 66 packages.
- **Lint, a proven narrowing** (`pnpm lint` itself is CI's run):
- population, from eslint's own config: 6 of the 7 changed paths are
linted (the changeset `.md` has no matching configuration);
- count, from `--format json`: 6 files, 0 errors, 0 warnings;
- invariance: `eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`), and its only import rule is per-file
(`no-restricted-imports`), so this diff cannot move the verdict on an
untouched file.
- `check:nul-bytes`: OK. Control-byte self-scan of the 7 changed files:
grep exit 1 (none).
## Acceptance notes
- **The metadata door accepts the hook that the bind then refuses.**
Saving a runtime-authored hook whose `object` is a family table answers
`200` at the metadata door (recorded by the composed pin). The bind then
refuses it and records the refusal at `error`, but nothing refuses it at
save. This is reported to the seat as an authoring-trap finding; it is
not fixed here (the save door and the spec are outside this card's
surface).
- An expected write refusal from an action body is logged by the
existing body-runner catch at `error` (`[BodyRunner] sandboxed action
threw`). That is the runner's existing posture for any body that throws,
and it is unchanged here.
- Flow record nodes and host code are outside this ruling (bodies only).
Action bodies declared on a family object are covered by the write
layer, like any body.
- An earlier head of this branch merged #21539's head while it was open.
#21539 has since landed, and `origin/main` was merged on top. The seam
file's #21539 bytes are identical to the landed squash, so this PR's
diff against `main` is exactly the 7 files listed by the gate
derivation.
## Changeset
`@objectstack/runtime` **minor** (the launch-window convention for
accept-set narrowings), with `Clause-②: yes (narrowing)` and the
ADR-0087 disposition `not-required (no-migration-prescription)`. No
stored metadata shape, authorable key or export moves.
---
_Generated by [Claude
Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1ca1eb0 commit bd70706
7 files changed
Lines changed: 981 additions & 18 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
57 | 57 | | |
58 | 58 | | |
59 | 59 | | |
60 | | - | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
61 | 67 | | |
62 | 68 | | |
63 | 69 | | |
| |||
290 | 296 | | |
291 | 297 | | |
292 | 298 | | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
293 | 313 | | |
294 | 314 | | |
295 | 315 | | |
| |||
301 | 321 | | |
302 | 322 | | |
303 | 323 | | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
304 | 336 | | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
305 | 342 | | |
306 | 343 | | |
307 | 344 | | |
| |||
795 | 832 | | |
796 | 833 | | |
797 | 834 | | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
798 | 841 | | |
799 | 842 | | |
800 | | - | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
801 | 846 | | |
802 | 847 | | |
803 | 848 | | |
| |||
0 commit comments