Repository navigation
fix(service-automation): flow write nodes refuse a stored-metadata family target (#21624) - #21649
Conversation
…mily target (#21624) create_record, update_record and delete_record refuse a target in the stored-metadata family (isStoredMetadataBodyObject) before they resolve their filter or field values and before any engine write, under either run identity, as a guard refusal carrying PERMISSION_DENIED with a prescription naming the metadata protocol. This also closes the write nodes' filter evaluate exit on the family tables. Rider: the NodeExecutionResult.code docblock names every executor that sets code today. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…snapshot read (#21624) Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 34 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 6 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 6e7baedd5226b3b0902e08fbb62a353c51f10168 && git checkout 6e7baedd5226b3b0902e08fbb62a353c51f10168
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 15fe567c9c74e088684944094aa686b9bd3b386c afbc55b76463ee1f894193d551d1565c6e27dc76 && git checkout -B drift-repro 15fe567c9c74e088684944094aa686b9bd3b386c && git merge --no-ff afbc55b76463ee1f894193d551d1565c6e27dc76
node scripts/docs-audit/affected-docs.mjs --json 15fe567c9c74e088684944094aa686b9bd3b386c
|
…nd an unknown --object is refused (objectstack-ai#21662) Fixes objectstack-ai#21644 Clause-②: no A deployment-level flag is now written only by a full-scope run. `os migrate value-shapes` and `os migrate files-to-references` narrowed by `--object` apply their fixes, record no deployment flag, and say so. A full-scope `--apply` records the flag exactly as before. Across the family (`value-shapes`, `files-to-references`, `summary-nulls`, `duplicates`), an `--object` name the deployment does not declare is refused with `OBJECT_NOT_FOUND` before anything is read or written. The refusal names the unknown name and the declared objects. This follows triage ruling `5974774596`. `--apply --object` is not refused. ## Measured first (base `759dbe9ed3`) ### A1. The reach, at the public door (hypothesis confirmed) A throwaway SQLite project held three objects, one of them `os21644_site` with a `location` field. A served-shape boot seeded one clean row per object. Then one off-shape value was written past the write path: the site's `geo` stored as `{latitude, longitude}`. The fresh-datastore attestation had recorded both ADR-0104 flags as verified at birth, so the flag table was emptied first. - `os migrate value-shapes --json` exited 1, with `gatePassed: false` and `blocking: 1`. - `os migrate value-shapes --object os21644_sitee --apply --yes --json` (misspelled) exited **0**. It answered `gatePassed: true` with `scannedObjects: []`, and the `adr-0104-value-shapes` row read **verified** (`verified_at` set, `blocking: 0`). ### A2. The census, one row per command | command | `--object` | `--apply` records a deployment flag | where it is written (base) | unknown `--object` on base (measured) | | --- | --- | --- | --- | --- | | `value-shapes` | repeatable | `adr-0104-value-shapes` | the CLI: `recordDataMigrationRun` at `value-shapes.ts:221` | exit 0, `scannedObjects: []`; with `--apply`, the flag is recorded **verified** | | `files-to-references` | repeatable | `adr-0104-file-references`, then the column step's `columns_moved_at` | the producer: `runFilesToReferencesMigration` at `files-to-references-migration.ts:120`; the column stamp is `recordFileColumnMove` in the CLI (`files-to-references.ts:472`) | exit 0, both scans' `scannedObjects: []`; with `--apply`, the flag is recorded **verified**, **and the column step moved `os21644_product.image` and stamped `columns_moved_at`** | | `summary-nulls` | repeatable | none (its header: "No deployment flag, deliberately") | none | exit 0, `fields: []`, on a dry run and on `--apply` | | `duplicates` | single | none: no `--apply`, and it writes nothing | none | exit 0, `scanned: []`, `filter: { object: 'os21644_sitee' }` | A correctly spelled narrowed `files-to-references --apply` on base also recorded the flag verified and moved the column. Every scan draws its default candidates from the same registry: `options.objects ?? Object.keys(engine.getConfigs())` in `scanValueShapes`, `backfillFileReferences`, `verifyFileReferences` and `backfillSummaryNulls`, and `stack.allObjects()` for `collectScanTargets`. Each keeps only the candidates it covers, which is where an undeclared name was dropped. ### A3. The narrowed run - **CLI-recorded flag (`value-shapes`).** The flag write is skipped on a narrowed run, whether the run passes or fails. `--json` carries `flag: null` and `filter: { objects }` (`null` on a full-scope run, the shape `duplicates` already keeps). Both faces print one sentence: the run was narrowed, no deployment flag was recorded, and the command that records one is the same command without `--object`. - **Producer-recorded flag (`files-to-references`).** `runFilesToReferencesMigration` skips the write when it is given `objects`. This is the declared `service-storage` path only. Its `flag` result is `null` on a narrowed run. The CLI prints the same sentence and carries `filter`. - **The column step (`files-to-references`) does not run on a narrowed run.** The census row above is why. The step retypes every single-value media column in the database on the authority of the gate, and a narrowed gate vouches only for the named objects. Its stamp also requires a verified flag, which a narrowed run no longer records. Left running, a narrowed `--apply` would move columns and then fail to record the move. It now returns a stated skip, `narrowed_run`, and the human face says why. - **What "narrowed" means.** Any `--object` narrows, even a list that names every declared object. The flag is earned by the one spelling that means "every object", which is a run without `--object`. Treating a full list as full scope would need a second definition of "the whole deployment", checked against the registry of the moment, and that registry changes with the composition between two runs. The operator also gets one unambiguous prescription. - **Deviation from the dispatch wording ("skips it when `objects` is non-empty").** The producer treats **any** `objects` as narrowed, `[]` included. A scan handed `[]` walks nothing (`[] ?? …` is `[]`). A non-empty test would therefore record a verified flag over an empty scan, the card's own defect at the producer's API. A unit pin holds this. - The prompts and closing lines that promised a flag on a narrowed run now say it records none. ⛔ `--apply --object` is not refused, and the full-scope write is unchanged. ### A4. Unknown `--object` - **Checked against the registry the command's own boot resolved, before the scan.** For `value-shapes`, `files-to-references` and `summary-nulls` that registry is `Object.keys(engine.getConfigs())`. For `duplicates` it is the names of `stack.allObjects()`. These are the same sets the scans draw from, so the refusal and the scan judge one population. There is no `packages/objectql` edit and no scanner edit. - **The refusal is objectstack-ai#21643's.** It is `objectNotFoundError` from `@objectstack/core`: `code: 'OBJECT_NOT_FOUND'`, `status: 404`, and `object` naming the first unknown name. Its message names every unknown name and the declared objects, sorted. There is no new error code. `value-shapes`, `files-to-references` and `summary-nulls` answer `{ error, code }`, as `unmapped-columns` does. `duplicates` keeps its own error shape, `{ error: 'report_failed', detail, code }`: its catch now passes `errorCodeFields` through. - **The list is the declared set, not the covered subset.** Computing the covered subset for `value-shapes` needs `isScannableValueShapeField`, which `@objectstack/objectql` does not export, and that package is fenced. The declared set is also exactly the accept set. A declared object the command has nothing to check on is accepted, because an empty answer about a real object is true. On the fixture boot the list is 12 names, platform objects included. - **Clause-②: no stands as the claim declared it.** A misspelled name moves from exit 0 to exit 1, which is the ruled correction of a wrong answer. Every declared name and `--apply --object` are still accepted. ## Changes - `packages/cli/src/utils/migrate-object-scope.ts` (new): `refuseUndeclaredObjects`, `isNarrowedRun` and `narrowedFlagNote`, shared by the four commands. - `packages/cli/src/commands/migrate/value-shapes.ts`: refuses an unknown name, skips the flag on a narrowed run, adds `filter`, and adjusts the narrowed prompt and closing lines. - `packages/cli/src/commands/migrate/files-to-references.ts`: refuses an unknown name, adds the `narrowed_run` column-step skip, adds `filter`, and adjusts the narrowed prompt and closing lines. - `packages/cli/src/commands/migrate/summary-nulls.ts` and `duplicates.ts`: refuse an unknown name. `duplicates`' error document carries the error's `code`. - `packages/services/service-storage/src/files-to-references-migration.ts`: skips the flag write when given `objects`. - `content/docs/deployment/cli.mdx`: one paragraph under "Data migrations" (`--object` narrows, an unknown name is refused, only a full-scope run records a flag), and the two `--object` example comments. - `.changeset/21644-narrowed-apply-flag.md`: `@objectstack/cli` patch and `@objectstack/service-storage` patch, `Clause-②: no`. `packages/objectql`, `packages/platform-objects`, `packages/spec`, every other `service-storage` path, and `content/docs/releases/` are untouched. ## Pins - **`object-scope.integration.test.ts`** spawns the CLI against SQLite, one database copy per run, and is one enumeration over the census (`FAMILY`). - A narrowed `--apply` (`value-shapes`, `files-to-references`): exit 0, `flag: null`, `filter: { objects }`, no flag row, and the note on stderr naming the full-scope command. - A full-scope `--apply`: the flag recorded verified, in the document and in the row. - `summary-nulls` and `duplicates`: no flag row, narrowed or not, as before. - A narrowed `--apply` after an earned flag leaves that row byte-equal. - `files-to-references` narrowed: `columnMove: null` and `columnsMovedAt: null`. Its full-scope control moves `os21644_product.image` and stamps it. - Unknown `--object`, on all four: exit 1 and `OBJECT_NOT_FOUND`, naming the name and the declared objects. The one document is the refusal and no report, no flag row is written, and the app rows are unchanged. The human face exits 1 and names it. - The measured repro. Control: the full-scope scan sees `blocking: 1` and exits 1. The misspelled `--object --apply` exits 1 with `OBJECT_NOT_FOUND`, and the flag stays unrecorded. Spelled right, the narrowed run finds the value, exits 1, and still records no flag. - **`migrate-object-scope.test.ts`** (unit): the envelope (`code`, `status`, `object`), every unknown name named once, the declared list sorted, the empty-registry message, the accepted cases, and what `isNarrowedRun` treats as narrowed (an empty list and a full list both narrow). - **`files-to-references-migration.test.ts`** (`service-storage`, beside the producer): - a narrowed apply converts and records no flag; - a narrowed failing apply records nothing; - a narrowed apply leaves an earned flag row equal; - `objects: []` records nothing. ## Reverse verification (implementation committed first; all three legs re-run at the final head `fe988c20f0`) Each leg ran through `node scripts/ablation-replace.mjs` in wrap mode, under a script trap that restores from `HEAD`. The spawned CLI loads its commands from `src/` through `bin/run-dev.js`. In the first round `packages/cli/dist` did not exist. In the final round it held a build of the unmutated source, and legs 1a and 2 still went red, which shows the spawned CLI read the mutated `src/`. The `service-storage` unit pin imports the producer from `src/`. Neither needed a rebuild. - **Leg 1a, the narrowed-run skip in the CLI** (`value-shapes.ts`): - The anchor `if (apply && !narrowed) {` became `if (apply) {`: anchor 1 to 0, replacement 0 to 1, blob `9f241dc2` to `d3a5c236`. - **3 red, 17 green.** Red: the `value-shapes` narrowed pin, the earned-flag-unchanged pin, and the spelled-right repro. Green: both full-scope controls (the column-step control among them), the `files-to-references` narrowed pin (its skip is the producer's), and every unknown-name pin. - Restored: blob `9f241dc2` equals `HEAD`, and `git diff HEAD` is empty. - **Leg 1b, the narrowed-run skip in the producer** (`files-to-references-migration.ts`): - The same anchor and replacement: anchor 1 to 0, blob `1aa9fea2` to `d4bb5002`. - **4 red, 6 green.** Red: all four narrowed pins (passing, failing, earned-flag-unchanged, and `objects: []`). Green: the six original pins, the full-scope apply among them. - Restored: blob `1aa9fea2` equals `HEAD`. - **A first round is recorded here because one of its readings was vacuous.** At `80e5eda6ea` this leg read 3 red and 7 green: the earned-flag-unchanged pin stayed green under the mutation, because the fake engine's rewrite landed in the same millisecond as the earned row. The pin now dates the earned row in the past (`fe988c20f0`), and the re-run is the reading above. - **Leg 2, the unknown-name refusal** (`migrate-object-scope.ts`): - The anchor `if (unknown.length === 0) return;` became `if (unknown.length >= 0) return;`: anchor 1 to 0, replacement 0 to 1, blob `b78a88b4` to `9286a990`. - **Unit: 3 red, 3 green.** Red: the three refusal cases. Green: the accepted cases and the two `isNarrowedRun` cases. - **Integration: 10 red, 10 green.** Red: all eight unknown-name pins (two per command, on all four), the human face, and the misspelled repro. Green: every narrowed and full-scope pin, and the repro's control. - Restored: blob `b78a88b4` equals `HEAD`. - In the first round, the `duplicates` "refused before anything was read" pin stayed green under this mutation: it asserted only on a key that report never carries. It now asserts that the one document is the refusal, which reds on all four commands. After all legs, `git diff HEAD` was empty and `git status --porcelain` was clean. ## Local verification (final head `fe988c20f0`, on base `759dbe9ed3`) `origin/main` was `759dbe9ed3` for the whole verification. Just before this PR opened, it gained four commits, `f40bb3217f` to `1a230548cf` (objectstack-ai#21649, objectstack-ai#21632, objectstack-ai#21648, objectstack-ai#21650). None of them touches this diff's paths (`packages/spec`, `metadata-protocol`, `service-automation`, `lint`, skills and docs references), so they were not merged in. CI runs on the merge ref. - **Builds.** The CLI's dependency closure (`turbo run build --filter=@objectstack/cli^...`) gave VERDICT 0. `@objectstack/service-storage` was rebuilt after the producer change (exit 0), and `@objectstack/cli` was built (exit 0). A repo build for the gate prerequisites gave VERDICT 0 (turbo: 72 tasks, 71 cached). - **`@objectstack/cli` typecheck** (`tsc --noEmit` plus `check:test-typecheck`): exit 0 at `80e5eda6ea`. No CLI file changed after that commit. - **`@objectstack/cli` unit project in full** at `80e5eda6ea`: - 255 of 257 files passed, with 3742 tests passed and 29 skipped (the two files below). - The other two files, `test/published-subpath-{console,hook-body}.pin.test.ts`, refused before testing because `packages/cli` was not built (their own prerequisite message). After the CLI build, both passed: 2 files, 29 tests. - **`@objectstack/service-storage`**: typecheck exit 0, and the full suite at `fe988c20f0` passed 41 files and 633 tests. - **`os migrate` integration pins on built packages**, at `80e5eda6ea`: - this PR's pin plus the absent-database roster: 2 files, 59 passed; - the one-shot family plus `duplicates.integration`: 2 files, 79 passed. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 97 commands at `fe988c20f0`. - All 97 ran there, and each exited 0. - `--ran` with exit codes: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN. - An earlier round at `80e5eda6ea` had three gates answer `PREREQUISITE NOT MET` (exit 3): `check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n-coverage`. They read packages outside the CLI closure. The repo build cleared them. - **Full `pnpm lint`** (`eslint . --no-inline-config` over the whole repo): exit 0 at `fe988c20f0`, with nothing printed. - **No exported symbol was renamed or moved,** so the liveness-ledger anchor check had nothing to read. ## Acceptance notes - **A narrowed run's counterexample is not recorded.** The ruling says a narrowed `--apply` records no flag, so it records none even when it finds a violation. Such a counterexample is deployment-level evidence, since one off-shape value disproves "every value is on shape". The operator still gets exit 1 and the findings, and the next full-scope run closes the gate. This is an observation, not a filing. Carrier: none. - **The declared list includes platform objects.** It is 12 names on the fixture's lean boot, and a deployment that composes more plugins prints more. A long list in an error message is the price of naming the exact accept set. Carrier: none. - **A narrowed `value-shapes --apply` still takes the plain (DDL-performing) boot** even though it now writes nothing. That is unchanged, and the boot paragraph in the docs still describes it truthfully. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ete_record node whose static objectName is a stored-metadata table, with the runtime's prescription (objectstack-ai#21654) (objectstack-ai#21687) Fixes objectstack-ai#21654 Clause-②: yes (narrowing) The save-time half of objectstack-ai#21624, route A as ruled (triage `5972908953`, the `domain:services` seat's answer `5974847898`). objectstack-ai#21624 remains open until both halves have landed; its seat owns that card. The run-time half is PR objectstack-ai#21649 (`f40bb3217f`). ## What changes - **The refusal.** `flowNodeConfigRefusals` (`packages/spec/src/automation/flow-node-config-refusals.ts`), the one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share, gains a third arm beside the executor-contract arm and the decision arm. A `create_record`, `update_record` or `delete_record` node whose `config.objectName` is a **static string** naming a stored-metadata table, judged by `isStoredMetadataBodyObject` by exact name, is refused at `nodes.N.config.objectName` (any depth, ADR-0031 regions included). The message names the node type and the table, uses the run-time refusal's verbs (create a record in / update / delete from) and ends on the run-time prescription. - **A new closed-set code.** `write-node-stored-metadata-target` joins `FLOW_SLOT_REFUSAL_CODES` with `params: { nodeType, objectName }` (`flow-node-expression-paths.ts`: the code table the judge's return type requires). - **Out of reach on purpose.** A dynamic `objectName` (a `{token}` template, an expression envelope) is not judged at save: the run judges the name it hands the data engine. A `get_record` node is not judged by this arm: a read is not a write. - **One prescription sentence.** The hook refusal's private `STORED_METADATA_BODY_PRESCRIPTION` moved, byte for byte, to the import-free leaf `kernel/stored-metadata-body-objects.ts` as an export. `data/hook.zod.ts` and the new flow arm both import it, and `kernel/metadata-type-redaction.ts` re-exports it beside the family set, so `@objectstack/spec/kernel` publishes it (`api-surface/kernel.json`, `export-origins/kernel.json` regenerated). In `hook.zod.ts` only the import line, the constant and the three comment lines describing it changed. The `handler` doc region that objectstack-ai#21604 holds is untouched. - **The ADR-0087 kit.** The D3 semantic entry `entries/semantic/18.flow-write-node-stored-metadata-target-refused.ts` (its prescription names the metadata protocol), its step-18 rationale fragment at order 74, the next free order on `main` at `7d0781482d` (71 to 73 are taken), and the regenerated `registry.ts` regions. No tombstone (no key is removed) and no D2 conversion (a refused node carries no intent a rewrite could keep). One BREAKING `@objectstack/spec` changeset with the `registered` disposition and the `Clause-②` line. ## Wording, set against the run-time refusal - Run time (`service-automation`, `storedMetadataWriteRefusal`): "create_record: refusing to create a record in 'sys_metadata': it holds stored metadata, and a flow may not write it directly, so the write was not run." Then the prescription. - Save time (this PR): "This `create_record` node's `objectName` is 'sys_metadata', so it would create a record in a table that holds stored metadata, and a flow may not write it directly: every run that reaches the node refuses it before anything is written, and re-running changes nothing." Then the prescription. - One residual difference, in the prescription itself: the run-time node spells "Elevation (`runAs: 'system'`)", while the shared constant (the hook refusal's, now exported) spells "Elevation (`runAs`, a system context)". Both say elevation does not change the outcome. Importing the exported sentence in `service-automation` makes them identical; that is a named follow-up, not done here. ## Census, before any edit At `417443eb27`: **0** write nodes aimed at either family table outside tests, across `packages/**`, `examples/**`, `skills/**`, `content/docs/**` and `docs/**` (229 write-node declarations). The only hits are the run-time half's own pins, `write-nodes-stored-metadata-family-refusal.integration.test.ts` (a static target at lines 270 and 271, and a parameterized one through `configFor`). Those pins are a test of the run-time refusal, not a writer. This PR re-expresses them (next section). The positive control fired: the same windowed search finds those pins and this PR's new tests. ## The run-time pins, re-expressed (claim revision `5977090032`, open question 1 answered A) The run-time half's pins (`packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts`, from PR objectstack-ai#21649) registered static family-target flows through `registerFlow` in order to run them. `registerFlow` parses first, so the save-time refusal turned 12 of their 17 cases red. The PM revised the claim to add this one file, test-only (claim revision `5977090032`; cross-lane declaration `5977094605` on objectstack-ai#21118). No other `service-automation` file changes, and the engine, `crud-nodes.ts` and the runtime are untouched. **The edit.** One new harness step, `registerForRun(def)`, replaces the two direct `registerFlow` calls (`runWatched` and `codeAsAFlowReadsIt`). - A definition with no static family target registers exactly as before. That covers the variable-target cases and the ordinary-object controls. - A definition carrying one is first judged at save. `registerFlow` must throw, with exactly one `custom` issue per family write node at its path: `nodes.1.config.objectName`, or `nodes.1.config.try.nodes.0.config.objectName` for the `try_catch` region flow. Each issue's message must name the metadata protocol. `getFlow(name)` must answer `null` (nothing was registered), and the target table's snapshot must be unchanged. - It then reaches the run-time guard with a definition the parse never judged. The same definition is registered aimed at a stand-in object (`pin_stand_in_target`, which does not exist). The family table is then put back on the parsed definition `registerFlow` returned. `getFlow(name)` must answer the family table at every original path before the run starts. - Every existing run-time assertion is unchanged, byte for byte: the run fails, nothing downstream runs, no engine write reaches the family, the table is unchanged, the flow reads `PERMISSION_DENIED`, a fault edge does not route, and all of it under both identities and both compositions. No assertion was removed or loosened. **The engine behaviour the route relies on, and why it is stable.** Two facts, neither touched by this PR, which changes no `service-automation` source file: - `AutomationEngine.registerFlow` stores the parsed definition it returns, by reference: `this.flows.set(name, parsed)`, then `return parsed`. - `execute` runs `this.flows.get(name)` as stored and never re-parses it. The engine documents `this.flows` as holding only `FlowSchema.parse` output. Both are read back rather than assumed. The `getFlow(name)` assertion above must see the family table at every retargeted path. If the engine ever copied, froze or re-parsed the stored definition, the retarget would fail loudly: a copy or a re-parse leaves the stand-in in place, so the read-back goes red and the run fails with not-found instead of the family refusal; a frozen definition throws on the assignment itself. The route cannot pass silently. **Evidence** (at `a9d2d5d453`): - The pins file: `Tests 17 passed (17)`. - The full `service-automation` suite: `Test Files 170 passed (170)`, `Tests 2098 passed (2098)`, 0 failed. `pnpm --filter @objectstack/service-automation typecheck` exits 0, and `check:test-typecheck` is OK. eslint on the file gives 0 errors and 0 warnings. - **Run-time guard ablation** (`crud-nodes.ts`, predicted 12 red / 5 green). `scripts/ablation-replace.mjs` pointed `storedMetadataWriteRefusal`'s family check at a name nothing matches. The anchor went 1 to 0 and the blob `b9bb559a0c` to `ffe005789b`. No build was needed: the pins reach it through relative `src` imports. Observed `Tests 12 failed | 5 passed (17)`. Every first failure is a run-time assertion ("the run must fail: expected true to be false"), and 0 are save-time assertions. So the save step passed, and the run then wrote, which the pins catch. Restore: blob after restore `b9bb559a0c` equals HEAD's, and `git diff HEAD` is empty; the script's own trap re-confirmed it with 0 diff lines. The first attempt used a one-line anchor that hits twice in the file (once in the `get_record` read refusal). The tool refused it (`ANCHOR AMBIGUOUS`, exit 3) and wrote nothing. The rerun used a longer anchor unique to the write refusal. - **Save-time arm ablation** (spec, predicted 12 red / 5 green). The arm's push became a `globalThis` marker assignment. The anchor went 1 to 0 and the blob `82a173479e` to `ee1614818e`. `@objectstack/spec` was rebuilt, and `ablation-dist-preflight.mjs` proved the marker reached `packages/spec/dist`, because `service-automation` resolves `@objectstack/spec` through `dist`. Observed `Tests 12 failed | 5 passed (17)`. Every failure is the new save-time assertion ("registerFlow must refuse a static family target at save: expected undefined to be defined"). Restore: blob `82a173479e` equals HEAD's and `git diff HEAD` is empty. The rebuilt `dist` was proven free of the marker (`--absent`, exit 0), and the rerun gave `17 passed`. ## Doors, tested and probed (all at `f7d1216a8f` unless noted) - `FlowSchema.parse`: refused at `nodes.1.config.objectName` for each write node and each family table, and at `nodes.1.config.try.nodes.0.config.objectName` inside a region. The message is the judge's own and ends on the leaf's prescription. - `defineStack`: `STACK_SCHEMA_INVALID` / 422 at `flows.1.nodes.1.config.objectName`. `ObjectStackDefinitionSchema` (the stack parse `objectstack validate` runs) refuses at `flows.0.nodes.1.config.objectName`. The registered `flow` type schema (the metadata save door's) and an artifact's parse refuse too. - `objectstack validate`, the real CLI on a temporary fixture (deleted afterwards): with a `create_record` node on `sys_metadata` it gave exit 1, `"code": "STACK_SCHEMA_INVALID"`, and an error naming `flows.0.nodes.1.config.objectName` and the prescription. The same stack aimed at an ordinary object gave exit 0, `"valid": true`. - `registerFlow`: refuses, because it parses first, and a committed pin now says so. The re-expressed run-time pins assert the throw, its path and the unchanged table for every static case (above). The throw comes from `FlowSchema.parse` inside `canonicalizeStoredFlow` (`engine.ts:4346`), which `registerFlow` (`engine.ts:4368`) calls. - Lit controls: each write node on an ordinary object passes. A dynamic `objectName` (`{record.target}`, `{target}`, and the envelope `{ dialect: 'cel', source: ... }`) passes at save, and the judge returns nothing for it. `get_record` on either family table passes. The refused set equals the predicate's by exact name (`SYS_METADATA`, `sys_metadata_draft`, ` sys_metadata` with a leading space, `sys_meta`, `metadata` all pass). ## Ablation of the spec pins (one-shot, not kept) At `f7d1216a8f`, with the implementation committed, `scripts/ablation-replace.mjs` replaced the arm's push line with a no-op plus a marker. On disk the anchor went 1 to 0, the marker 0 to 1, and the blob `82a173479e` to `a832964b45`. No dist rebuild was needed: the tests reach the judge through relative `src` imports. Predicted 17 red / 29 green over the two pin files; observed `Tests 17 failed | 29 passed (46)`. Restore: the tool reported blob after restore equal to the HEAD blob (`82a173479e`) and `git diff HEAD` empty. The script's own EXIT/INT/TERM trap, using `git checkout HEAD --` on the absolute path plus a hash compare, re-confirmed it with 0 diff lines. The rerun gave 46 passed. An earlier run at `8f5adb6ad6` (before the stack-parse door test existed) read 16 / 29, as predicted. ## Verification Spec-side readings at `f7d1216a8f`. `packages/spec` has not changed since; round 2 touched only the `service-automation` test file. - `@objectstack/spec`, full local project: `Test Files 611 passed (611)`, `Tests 18141 passed | 1 todo`. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `check:test-typecheck` is OK, and `tsc -p tsconfig.test.json --listFiles` lists both edited test files. - `check:generated`: all 15 artifacts up to date, against a dist the run built. - `@objectstack/lint` (the judge's other caller, `validateStackExpressions`): `Test Files 119 passed`, `Tests 5627 passed`. - Lint, a proven narrowing (`pnpm lint` itself belongs to CI). eslint's config lints 9 of the 12 round-1 paths: 0 errors and 0 warnings from `--format json --no-inline-config`. It ignores the 3 that are `.md` / `.json`. The config sets no `parserOptions.project` and enables no typed rules, so this diff cannot move an untouched file's verdict. Readings at `a9d2d5d453` (the head): - `@objectstack/service-automation`: `Test Files 170 passed (170)`, `Tests 2098 passed (2098)`. Typecheck exits 0. - eslint on the round-2 file: 0 errors and 0 warnings. The control-byte scan over the 13 changed paths found none. - `dispatch-gates --commands` (no paths), derived fresh at this head: 94 families, the round-1 92 plus `check-tenant-audit-census` and its self-test. All 94 were run fresh on this head, each exited 0, and `--ran` with exit codes recorded reads 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN. `origin/main` was merged twice through `scripts/pm/os-regen-merge.sh` (no rebase): once for objectstack-ai#21668 / objectstack-ai#21673, which landed `registry.ts` changes, and once for `7d0781482d`. Each merge regenerated and re-checked the artefacts afterwards. The sibling entries are all present (each id counted the same on `origin/main` and here), and this branch's `registry.ts` delta against `main` is +61 / -0. ## Acceptance notes - The comment above the `flowNodeConfigRefusals` walk in `packages/spec/src/automation/flow.zod.ts` still says "Two arms" and lists two. With this PR there are three. The file is outside the claim's surface, so it is noted, not edited. The judge's own docblock in `flow-node-config-refusals.ts` states all three arms. - `packages/lint/src/validate-expressions.ts` describes its call into the judge as "a key its contract requires, left out, and a `decision` branch list it cannot read". That list is now incomplete, not false: the call also emits the new refusal, as `error`. - Follow-up, not done here: `service-automation`'s `storedMetadataWriteRefusal` and the runtime body boundary's private `PRESCRIPTION` can import `STORED_METADATA_BODY_PRESCRIPTION` from `@objectstack/spec/kernel`, which makes the sentence one. Body refreshed 2026-10-04T06:42Z (round 2: the run-time pins re-expressed). The docs-drift advisory on this PR was read: it is advisory, and a spot-check of the hand-written pages naming `sys_metadata` with flows found none that this change falsifies. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…hared prescription sentence (objectstack-ai#21624) (objectstack-ai#21707) Fixes objectstack-ai#21624 Clause-②: no Part 3 of 3 on this card: the follow-up the seat's ACCEPT named and the spec lane's pointer handed back. Part 1, the run-time refusal, landed as PR objectstack-ai#21649 (`f40bb3217f`). Part 2, the save-time `FlowSchema` refusal, landed as PR objectstack-ai#21687 (`a2aadab1c6`). With this PR, the run-time and save-time refusals end on one sentence, and the card is complete. ## What changes - `packages/services/service-automation/src/builtin/crud-nodes.ts`, `storedMetadataWriteRefusal`: the message keeps its node-specific lead (the node type, what it would have done and the target table, and "so the write was not run"). It now ends on `STORED_METADATA_BODY_PRESCRIPTION`, imported from `@objectstack/spec/kernel` beside `isStoredMetadataBodyObject`, which the file already imported from that subpath. Its docblock gains one paragraph that names the shared sentence. - The only wording change an author sees is the elevation clause. It was "Elevation (`runAs: 'system'`) does not change this." and is now "Elevation (`runAs`, a system context) does not change this." The rest of the closing sentence was already byte-identical to the constant. - `write-nodes-stored-metadata-family-refusal.integration.test.ts`: the two message-text assertions (the save-time issue message and the run-time run error) used to check for a restated fragment, `toContain('the metadata protocol')`. Each now asserts that the message ENDS on the imported constant, through a small `closingPrescriptionOf` helper. Every refusal, no-write, code and identity assertion is unchanged, and none is deleted. - A `patch` changeset for `@objectstack/service-automation`. What is unchanged: the set of refused writes, the `PERMISSION_DENIED` code, the guard classification (a `fault` edge does not route it), and every non-family write. ## The dispatch's assumptions, as measured 1. **The constant's wording is true for every run-time caller.** The refusal runs before any identity is resolved, and the flow engine elevates in one way only: `runAs: 'system'` (`resolveRunDataContext` gives `isSystem: true`). "Elevation (`runAs`, a system context) does not change this" therefore holds for each run-time path. No sentence becomes false, and the constant was not touched. 2. **The `@objectstack/spec/kernel` subpath already reaches this package's build and tests.** `@objectstack/spec` is a declared dependency, and `crud-nodes.ts` already imported from `@objectstack/spec/kernel`. The vitest config has no source alias for `@objectstack/spec`, so tests reach it through `exports` (the spec package's built `kernel` entry). The source module and the pin read the same object. That pair is already recorded in `check-test-source-alias.mjs`'s `KNOWN_UNALIASED_TEST_IMPORTS` for this package, and `check:test-source-alias` exits 0. The built `dist/index.js` carries 2 hits for the constant and 0 for the old clause, and `check:dual-build-cjs-loads` exits 0. 3. **The pins import the constant.** No assertion was deleted (see above). 4. **Ablation:** see below. Predicted and observed agree. 5. **The runtime body boundary's private `PRESCRIPTION`** (`packages/runtime/src/stored-metadata-body-boundary.ts`, `domain:cli`): it is byte-identical to the shared constant (211 bytes each, compared programmatically). It is a copy, not an import. Not edited; see the acceptance notes. ## Verification (all at `1b9252e0f2` unless stated) Every heavy run went through `scripts/pm/os-verify-lock.sh`, and each verdict below is read from its `VERDICT command-exit` line. - `pnpm --filter @objectstack/service-automation test` (`vitest run`): **Test Files 170 passed (170), Tests 2098 passed (2098)**, exit 0. The same reading was taken at `d589cd4418`, before `origin/main` (`8843505d91`, objectql only) was merged in and the closure rebuilt. - The pin file alone, at `d589cd4418`: 17 passed (17). - `pnpm --filter @objectstack/service-automation typecheck`: exit 0, and `check:test-typecheck` OK (0 files in the ledger). `tsc --noEmit --listFiles -p tsconfig.json` lists the pin file (1 hit) and `crud-nodes.ts` (1 hit). - **Ablation, with the direction predicted before the run.** The mutation appends one word to the run-time closing sentence, inside the sentence. The anchor was `+ STORED_METADATA_BODY_PRESCRIPTION,` in `crud-nodes.ts`, and the replacement calls `.replace('change this.', 'change this ABLATIONMARKER.')` on it, applied with `scripts/ablation-replace.mjs`. - Prediction: 1 file red. 9 tests red, every case that goes through `expectRefused` (6 node x identity cases without the security plugin, 3 node cases with it), each failing first on the run-time "ends on the family's prescription" assertion. 8 green. The save-time assertion never red. Package total: 9 failed / 2089 passed. - On disk: the anchor went 1 to 0 and the replacement 0 to 1, and the blob changed from `86ed89180f` to `76faa10823`. The subject is reached through relative `src` imports (`../plugin.js`, then `./builtin/index.js`), so no `dist` rebuild or preflight applies. - Observed, full package suite: **Test Files 1 failed and 169 passed (170); Tests 9 failed and 2089 passed (2098)**. All 9 `AssertionError`s are the run-time prescription assertion, and 0 are the save-time one. - Restore, as reported by the tool: the blob after restore is `86ed89180f` and equals HEAD's, and `git diff HEAD` is empty. An own `trap` (`git checkout HEAD --` on the absolute path, then a hash comparison) re-confirmed it with 0 diff lines, and porcelain was empty. - Lint, as a proven narrowing (`pnpm lint` itself belongs to CI): - Population, read from eslint's own config: 2 of the 3 changed paths are linted. For the changeset, eslint answers "File ignored because no matching configuration was supplied". - Count, from `--format json --no-inline-config`: 3 results, with 0 errors and 0 warnings on the 2 TS files. - Invariance: `eslint.config.mjs` sets no `parserOptions.project` and no `projectService`, so the linting is not type-aware and this diff cannot move an untouched file's verdict. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths) derives 64 commands (30 pnpm, 34 node), the same list before and after the merge. - All 64 were run. 63 exited 0 on the first pass. - `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: unbuilt packages, nothing measured). After a full `turbo run build` (72/72) it exited 0, measuring 106 entries across 66 packages. - `--ran` reconciliation: **64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN**, exit 0. `check:nul-bytes` exited 0, and a control-byte scan of the 3 changed files found none. - Docs and skills: `content/docs/**` (outside `releases/`) and `skills/**` hold 0 copies of the old run-time sentence. The positive control was the same patterns on `crud-nodes.ts` before the edit (3 hits), and the pathspec control was `create_record` in `content/docs/automation/flows.mdx` (7 hits). Nothing to edit. ## Acceptance notes - **The runtime body boundary's `PRESCRIPTION` is a second copy of the sentence**, in `packages/runtime/src/stored-metadata-body-boundary.ts`. Today it is byte-identical to `STORED_METADATA_BODY_PRESCRIPTION`, so no author reads two wordings. But the constant's own docblock says to import it rather than restate it, and a later rewording would leave this copy behind. That file already imports `isStoredMetadataBodyObject` from `@objectstack/spec/kernel`, so the change is one import. It is another lane's file (`domain:cli`) and is not edited here. Carrier: none. - The spec's ADR-0087 semantic migration prescriptions for the hook and flow refusals restate the same elevation clause as frozen text. They are in the spec lane and match the shared wording. - The unreleased part 1 changeset describes the refusal in prose and does not quote the elevation clause, so it is not made false. It is not edited, since it is not this PR's changeset. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ Co-authored-by: Claude <noreply@anthropic.com>
Part of #21624
Clause-②: no
This PR carries the card's run-time half. The save-time half is not in this package; it is named below for the seat as an open question, and #21624 remains open for it.
What this changes
A flow's
create_record,update_recordanddelete_recordnodes no longer write the stored-metadata family (the current metadata table and its version history, judged by the family's own predicateisStoredMetadataBodyObjectfrom@objectstack/spec/kernel). Triage's ruling on the card, "refuse at the node", applies the reason of #21520's ruling A (the family has one writer for app-authored work, the metadata protocol) to flows.crud-nodes.tsgainsstoredMetadataWriteRefusal(nodeType, objectName). For a family target it returns a guard refusal (refuseNode, the node's existing channel) that carries the standard catalog'sPERMISSION_DENIED. The message names the metadata API and the metadata protocol as the way to change metadata, and says that elevation does not change this. Any other object getsundefined.objectNamecheck. That is before the filter is interpolated or the erased-condition guard runs, beforefieldsis resolved, and before the data engine is looked up. So a refused node answers the same whatever its filter or payload names, and nothing is computed or written. This also shuts the write nodes' evaluate exit: theirfilteris never run against a family table.objectNamestring the executor hands the engine. The write executors do not interpolateobjectName, which was measured: a{token}target reaches the engine as the literal template.NodeExecutionResult.codedocblock inengine.tsnamed one executor as "the only executor that sets it today".get_record(PR fix(service-automation): the flow get_record node refuses a filter that evaluates the stored-metadata family, with the data door's refusal (#21623) #21641) and now the three write nodes set it too. The sentence now names each executor and the code it sets.Files:
crud-nodes.ts,engine.ts(the docblock rider, one sentence), a new pin file and a changeset. There is nometadata-protocol,packages/specor lint edit.Census, taken before any edit
The census asked which shipped flow has a
create_record,update_recordordelete_recordnode whose target is a family table. A read-only scanner found every write-node literal (type: 'WRITE_NODE'in TS, JS, JSON, YAML and MD) and read theobjectName/objectkey from the same node object. A literal target was classified as family or non-family. A non-literal target (a variable, a template, or none) was listed and read by hand.packages/**, non-test (96b0e3108a)examples/**, non-testgit grepfinds)skills/**content/docs/**9466837registerFlowcallers are the flow loader and the/automationwrite doors; neither builds a flow of its own.objectNameraw and never interpolate it, which the reproduction below confirms. So a write node's target is always the literal in its config. The census's non-literal column covers TS-built configs.delete_recordand oneupdate_recordaimed at the two family tables gave 2 of 2 family hits. The same scanner, run overget_recordin test files, listed the sibling read pins' parameterized family targets as non-literal (8 hits). The family names themselves occur in 383 non-test package files, for example the platform's own list views inmetadata-core.Census result: zero. No shipped flow and no platform flow writes the family through a data node, so the refusal lands over no platform writer.
Reproduction on
mainbefore the fix (by class)The composition was
ObjectKernel,ObjectQLPlugin, the realAutomationServicePlugin, anddriver-sqlon better-sqlite3:memory:. Every case was run without the security plugin and with the realSecurityPlugin(the default permission sets). No stored value is recorded here.runAs: 'system'andrunAs: 'user', changed the table (12 of 12). That includesdelete_record, which was not measured on the card.runAs: 'system'changed the table in 6 of 6 cases (three nodes, two tables), since the elevated write skips the middleware.runAs: 'user'was refused in 12 of 12 cases, for both a rank-and-file member and a platform administrator. The refusal came from the middleware's ADR-0103 engine-owned write guard. It surfaced as a routable runtime failure with no code, and the table was unchanged.update_record(multi: true, system identity) whose filter read the stored body column acted on the row (acted: 1) for a matching guess and on nothing (acted: 0) for a non-matching one.objectName: '{record.target}') is not interpolated: the engine was asked for the literal template, answered "not found", and the family table was unchanged.The code: why
PERMISSION_DENIEDThe dispatch asked which code the generic data door answers for a direct family write, so it could be reused. Two answers were measured:
/data/:objectwrite verbs on either table answer 405OBJECT_API_METHOD_NOT_ALLOWED. That answer comes fromapiAccessDenialFromEnableover the tables'apiMethods: ['get', 'list']. Measured throughapiExposureDenialReason, the spec helper that function wraps: create, update and delete givemethod-not-allowed.createData/updateData/deleteData) answers a non-platform principal withPERMISSION_DENIED/ 403 in a secured composition, for both member and administrator. The system context is admitted, since it is the platform's own path.The node carries
PERMISSION_DENIED. The 405 is the REST exposure gate, and its own docblock scopes it to the external API: "Internal callers (hooks, flows, raw objectql) are unaffected". The 403 is the door's answer to the condition the node refuses: a principal that is not the platform may not write these tables. It is also the code that ruling A's body-write boundary (stored-metadata-body-boundary.ts) carries for the same rule. No code is minted, andPERMISSION_DENIEDis a standard catalog member.The save-time half is not in this package (open question for the seat)
Triage's third pin asks that a flow naming a family target statically be refused at save. Read from the code, the save doors judge a flow as follows:
/metasave door (Studio, REST/meta, MCP authoring) runssaveMetaItem, which does three things. It canonicalizes the flow through the automation engine; a throw there is caught and the raw body is kept. It then runs the per-typeFlowSchemasafeParse. Finally, the runtime authoring gate inmetadata-protocolruns the@objectstack/lint/runtimerules.registerFlowis not on that path. The engine registers the stored row afterwards, on the publish rebind.FlowSchema.parseinpackages/spec.flow-node-config-refusals.tsdescribes itself as "the one judgeFlowSchema.parse,AutomationEngine.registerFlow(which parses first) andobjectstack validateshare".HookSchema(data/hook.zod.ts,refuseBodyOnStoredMetadataTarget).So the save-time refusal belongs outside
service-automation, and per the claim nothing there is edited here. The position and the edit it needs are in the report on the card, as an open question for a claim revision. The run-time refusal alone shuts the reach.Pins
New file:
packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts, 17 cases on the composition above.insert/update/deleteis never called on a family table, and the table snapshot is unchanged;PERMISSION_DENIEDon{$error.code}and on atry_catchregion's error variable.runAs: 'system', underrunAs: 'user'as a member, and underrunAs: 'user'as a platform administrator. The same assertions apply.PERMISSION_DENIED/ 403.update_recordand adelete_recordwhose filter reads the stored body, with a matching and a non-matching guess, give the same refusal text, no engine write and an unchanged table.faultedge on the node, the run still fails and the handler never runs, for all three nodes.Ablation
The fix was committed first. The direction was predicted before the run. With the refusal's effect removed, the 12 refused-case pins would go red, because the write runs and succeeds. The 5 controls would stay green: the data door control, the two variable-target cases (the refusal never applied to them) and the two non-family cases.
scripts/ablation-replace.mjsreplaced the family test at the head ofstoredMetadataWriteRefusalwith an always-undefinedreturn carrying a marker. The anchor went from 1 to 0 and the marker from 0 to 1, and the file's blob changed. The subject is reached through a relativesrcimport, so nodistrebuild or preflight applies.git diff HEADempty. A bash trap (git checkout HEAD --on the absolute path, then a blob comparison) re-confirmed it with 0 diff lines.git status --porcelainshowed 0 lines and the marker grep 0. The re-run gave 17 passed (17).1e7d538944, at233d3206e7and atafbc55b764(the final head), with identical readings each time.Verification
Every reading is at HEAD
afbc55b764. That head mergedorigin/mainat15fe567c9c(three commits, none in this package) and then rebuilt the tree (turbo run buildexcluding docs, 72 of 72 tasks). Every heavy run went throughscripts/pm/os-verify-lock.sh.pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2(the whole suite, the new pins included): Test Files 169 passed (169), Tests 2095 passed (2095), VERDICT command-exit 0.pnpm --filter @objectstack/service-automation typecheck: VERDICT command-exit 0, andcheck:test-typecheckis OK.tsc --listFilesshows the new pin file in bothtsconfig.jsonandtsconfig.test.json.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths) derives 65 commands atafbc55b764(31 pnpm and 34 direct node), with no stale-tree warning.--ranreconciles them: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN (exit 0).check:dual-build-cjs-loadsmeasured 106 entries across 66 packages on the built tree. At the first pass (1e7d538944, unbuilt tree) it answered PREREQUISITE NOT MET (exit 3), which measured nothing.check:query-options-erasurewas red at that first pass: the pin file's snapshot read added one untyped engine-options site to the test surface (236 to 237). The read was typed in233d3206e7, and the gate holds at 236.origin/main(a workflow file and the SDUI manifest record changed). After the merge, the same 65 commands derive.check:nul-bytesexited 0. No turbo-driven gate left anAGENTS.mdblock, andgit status --porcelainwas empty after each pass.pnpm lintitself is CI's):crud-nodes.ts,engine.tsand the pin file). For the changeset, eslint answers "no matching configuration".--format jsonwith--no-inline-config: 0 errors and 0 warnings on the 3 linted files.eslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move the verdict on an untouched file.Docs
content/docs/**(outsidereleases/) andskills/**were searched for flow data nodes writing system or metadata tables. No sentence states that a write node may target them, so none is made false and nothing is edited. Notes:content/docs/automation/flows.mdxhas an "In practice that is …" list. It does not name this refusal (nor the read-node one), so the list is incomplete, not false.{$error.code}row there says "e.g.create_record'sDUPLICATE_RECORD", which stays true.Acceptance notes
.changeset/21623-flow-read-node-evaluate-refusal.mdsays "The write nodes are unchanged", which was true of that change. Both changesets compile into the same release, and this PR's changeset states this change. This PR does not edit a changeset it did not add. Carrier: none.registerFlowwas deliberately not given a second static check. It parsesFlowSchemafirst, so the save-time judge, wherever the seat places it, reaches it without a copy here.Generated by Claude Code