Commit a2aadab
Fixes #21654
Clause-②: yes (narrowing)
The save-time half of #21624, route A as ruled (triage `5972908953`, the
`domain:services` seat's answer `5974847898`). #21624 remains open until
both halves have landed; its seat owns that card. The run-time half is
PR #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 #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 #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 #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 #21668 / #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>
1 parent fe10172 commit a2aadab
13 files changed
Lines changed: 658 additions & 23 deletions
File tree
- .changeset
- packages
- services/service-automation/src/builtin
- spec
- api-surface
- export-origins
- src
- automation
- data
- kernel
- migrations
- entries/semantic
Lines changed: 35 additions & 0 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 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
Lines changed: 117 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
36 | 45 | | |
37 | 46 | | |
38 | 47 | | |
| |||
60 | 69 | | |
61 | 70 | | |
62 | 71 | | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
63 | 115 | | |
64 | 116 | | |
65 | 117 | | |
| |||
169 | 221 | | |
170 | 222 | | |
171 | 223 | | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
172 | 286 | | |
173 | 287 | | |
174 | | - | |
| 288 | + | |
175 | 289 | | |
176 | 290 | | |
177 | 291 | | |
| |||
193 | 307 | | |
194 | 308 | | |
195 | 309 | | |
196 | | - | |
| 310 | + | |
197 | 311 | | |
198 | 312 | | |
199 | 313 | | |
| |||
208 | 322 | | |
209 | 323 | | |
210 | 324 | | |
211 | | - | |
| 325 | + | |
212 | 326 | | |
213 | 327 | | |
214 | 328 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
375 | 375 | | |
376 | 376 | | |
377 | 377 | | |
| 378 | + | |
378 | 379 | | |
379 | 380 | | |
380 | 381 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
373 | 373 | | |
374 | 374 | | |
375 | 375 | | |
| 376 | + | |
376 | 377 | | |
377 | 378 | | |
378 | 379 | | |
| |||
0 commit comments