You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit a2aadab
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec)!: FlowSchema refuses a create_record, update_record or delete_record node whose static objectName is a stored-metadata table, with the runtime's prescription (#21654) (#21687)
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>
A flow `create_record`, `update_record` or `delete_record` node whose `objectName` is `sys_metadata` or `sys_metadata_history` is refused at parse, with the runtime's prescription: change metadata through the metadata API.
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
12
+
13
+
**Why.** App-authored work may not write the two stored-metadata tables: the metadata protocol is their only writer, where a change is validated and its provenance is recorded, and a flow is app-authored automation. The runtime already enforces that at the node: the three write nodes refuse such a target before they resolve a filter, compute a field or call the data engine, under every run identity. But `FlowSchema` still accepted the flow, so `objectstack validate` passed it, the metadata save door answered 200 for it and `registerFlow` registered it, and the author learned otherwise only at its first run.
14
+
15
+
**What is refused.** A `create_record`, `update_record` or `delete_record` node, at any depth including an ADR-0031 region body, whose `config.objectName` is a string naming `sys_metadata` or `sys_metadata_history`. The issue's `code` is `custom`, at `nodes.N.config.objectName`, and its message names the node type and the table and ends with the runtime's prescription. The judge is `flowNodeConfigRefusals`, the one `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share, and its membership test is the kernel's own `isStoredMetadataBodyObject`, the predicate the runtime judges by. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.objectName`), `os validate`, an artifact's parse, `registerFlow` and the metadata save door (`422 INVALID_METADATA`). The refusal joins the closed flow slot refusal set as `write-node-stored-metadata-target`, with `params: { nodeType, objectName }`.
16
+
17
+
**What stays accepted, byte for byte.** A `get_record` node on those tables (a read is not a write; the runtime judges its reach at the run), a write node whose `objectName` is dynamic (a `{token}` template or an expression envelope: the parse cannot read it as a name, and the runtime judges the name it hands the data engine), and every write node on any other object.
18
+
19
+
**One prescription sentence.**`@objectstack/spec/kernel` now exports `STORED_METADATA_BODY_PRESCRIPTION`, the sentence the hook refusal and this flow refusal both end on. It was the hook refusal's private constant, moved unchanged.
20
+
21
+
## FROM → TO
22
+
23
+
| you wrote | write instead |
24
+
|:--|:--|
25
+
| a `create_record` / `update_record` / `delete_record` node with `objectName: 'sys_metadata'` or `objectName: 'sys_metadata_history'`| change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`) instead, and delete the node |
26
+
| a write node on any other object, a `get_record` node, or a dynamic `objectName`| unchanged |
27
+
28
+
**The one-line fix: delete the node, or point its `objectName` at the object the flow really means to write, and make the metadata change through the metadata API.** The runtime never ran such a write, so removing it changes nothing a flow does.
29
+
30
+
**Who is affected, measured.** No authored flow writes either table in this repository's `packages/**`, `examples/**`, `skills/**`, `content/docs/**` or `docs/**` at `417443eb27` (229 write-node declarations); the only hits are the runtime's own tests of its node refusal. Deployed metadata was not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register.
31
+
32
+
### The kit
33
+
34
+
-**The refusal.** A third arm of `flowNodeConfigRefusals` (`automation/flow-node-config-refusals.ts`), beside the executor-contract arm and the decision arm.
35
+
-**The ledger.** The D3 semantic entry `flow-write-node-stored-metadata-target-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: a refused node carries no intent a rewrite could keep.
Copy file name to clipboardExpand all lines: packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts
+117-3Lines changed: 117 additions & 3 deletions
Original file line number
Diff line number
Diff line change
@@ -33,6 +33,15 @@
33
33
* a flow variable leaves the family table unchanged, and the three nodes write
34
34
* an ordinary object exactly as before.
35
35
*
36
+
* [#21654] The save-time half. `FlowSchema` now refuses a write node whose
37
+
* STATIC `objectName` names a family table, and `registerFlow` parses first,
38
+
* so a flow carrying one is refused before it can run. Every static case here
39
+
* therefore asserts that refusal first (`registerFlow` throws, the issue sits at
40
+
* the node's `config.objectName`, the table is unchanged), and only then reaches
41
+
* the run-time guard, with a definition the parse never judged: see
42
+
* {@link registerForRun}. A dynamic target (`{record.target}`) is not judged at
43
+
* save and registers as before.
44
+
*
36
45
* Composition: `ObjectKernel`, `ObjectQLPlugin`, `driver-sql` on
37
46
* better-sqlite3 `:memory:` and the real `AutomationServicePlugin`, the stack
38
47
* the family read pins boot; the secured composition adds the real
@@ -60,6 +69,49 @@ type RunAs = 'system' | 'user';
0 commit comments