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
Conversation
… table Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
… the flow write-node family refusal Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…ow-family-write-refused
…he next free after main's 71-73 Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…y-target write node at its path Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…ow-family-write-refused
📓 Docs Drift CheckThis PR changes 1 package(s): 33 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 — 138 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 223e4990b432e5e3467eead02eb42e28b2916091 && git checkout 223e4990b432e5e3467eead02eb42e28b2916091
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 a9d2d5d453d51b67cedda80947c7b3e4caedd490 && git checkout -B drift-repro 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 && git merge --no-ff a9d2d5d453d51b67cedda80947c7b3e4caedd490
node scripts/docs-audit/affected-docs.mjs --json 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8
|
…me refusal first, then run a definition the parse never judged Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21654
Clause-②: yes (narrowing)
The save-time half of #21624, route A as ruled (triage
5972908953, thedomain:servicesseat's answer5974847898). #21624 remains open until both halves have landed; its seat owns that card. The run-time half is PR #21649 (f40bb3217f).What changes
flowNodeConfigRefusals(packages/spec/src/automation/flow-node-config-refusals.ts), the one judgeFlowSchema.parse,AutomationEngine.registerFlow(which parses first) andobjectstack validateshare, gains a third arm beside the executor-contract arm and the decision arm. Acreate_record,update_recordordelete_recordnode whoseconfig.objectNameis a static string naming a stored-metadata table, judged byisStoredMetadataBodyObjectby exact name, is refused atnodes.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.write-node-stored-metadata-targetjoinsFLOW_SLOT_REFUSAL_CODESwithparams: { nodeType, objectName }(flow-node-expression-paths.ts: the code table the judge's return type requires).objectName(a{token}template, an expression envelope) is not judged at save: the run judges the name it hands the data engine. Aget_recordnode is not judged by this arm: a read is not a write.STORED_METADATA_BODY_PRESCRIPTIONmoved, byte for byte, to the import-free leafkernel/stored-metadata-body-objects.tsas an export.data/hook.zod.tsand the new flow arm both import it, andkernel/metadata-type-redaction.tsre-exports it beside the family set, so@objectstack/spec/kernelpublishes it (api-surface/kernel.json,export-origins/kernel.jsonregenerated). Inhook.zod.tsonly the import line, the constant and the three comment lines describing it changed. Thehandlerdoc region that [Decision] security(objectql): may a hook'shandlername bind to a function another package registered (the engine-wide fallback HookSchema.handler declares), or does name resolution stay inside the hook's own package (#21585 option B) #21604 holds is untouched.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 onmainat7d0781482d(71 to 73 are taken), and the regeneratedregistry.tsregions. No tombstone (no key is removed) and no D2 conversion (a refused node carries no intent a rewrite could keep). One BREAKING@objectstack/specchangeset with theregistereddisposition and theClause-②line.Wording, set against the run-time refusal
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.create_recordnode'sobjectNameis '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.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 inservice-automationmakes 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, acrosspackages/**,examples/**,skills/**,content/docs/**anddocs/**(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 throughconfigFor). 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 throughregisterFlowin order to run them.registerFlowparses 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 revision5977090032; cross-lane declaration5977094605on #21118). No otherservice-automationfile changes, and the engine,crud-nodes.tsand the runtime are untouched.The edit. One new harness step,
registerForRun(def), replaces the two directregisterFlowcalls (runWatchedandcodeAsAFlowReadsIt).registerFlowmust throw, with exactly onecustomissue per family write node at its path:nodes.1.config.objectName, ornodes.1.config.try.nodes.0.config.objectNamefor thetry_catchregion flow. Each issue's message must name the metadata protocol.getFlow(name)must answernull(nothing was registered), and the target table's snapshot must be unchanged.pin_stand_in_target, which does not exist). The family table is then put back on the parsed definitionregisterFlowreturned.getFlow(name)must answer the family table at every original path before the run starts.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-automationsource file:AutomationEngine.registerFlowstores the parsed definition it returns, by reference:this.flows.set(name, parsed), thenreturn parsed.executerunsthis.flows.get(name)as stored and never re-parses it. The engine documentsthis.flowsas holding onlyFlowSchema.parseoutput.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):Tests 17 passed (17).service-automationsuite:Test Files 170 passed (170),Tests 2098 passed (2098), 0 failed.pnpm --filter @objectstack/service-automation typecheckexits 0, andcheck:test-typecheckis OK. eslint on the file gives 0 errors and 0 warnings.crud-nodes.ts, predicted 12 red / 5 green).scripts/ablation-replace.mjspointedstoredMetadataWriteRefusal's family check at a name nothing matches. The anchor went 1 to 0 and the blobb9bb559a0ctoffe005789b. No build was needed: the pins reach it through relativesrcimports. ObservedTests 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 restoreb9bb559a0cequals HEAD's, andgit diff HEADis 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 theget_recordread refusal). The tool refused it (ANCHOR AMBIGUOUS, exit 3) and wrote nothing. The rerun used a longer anchor unique to the write refusal.globalThismarker assignment. The anchor went 1 to 0 and the blob82a173479etoee1614818e.@objectstack/specwas rebuilt, andablation-dist-preflight.mjsproved the marker reachedpackages/spec/dist, becauseservice-automationresolves@objectstack/specthroughdist. ObservedTests 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: blob82a173479eequals HEAD's andgit diff HEADis empty. The rebuiltdistwas proven free of the marker (--absent, exit 0), and the rerun gave17 passed.Doors, tested and probed (all at
f7d1216a8funless noted)FlowSchema.parse: refused atnodes.1.config.objectNamefor each write node and each family table, and atnodes.1.config.try.nodes.0.config.objectNameinside a region. The message is the judge's own and ends on the leaf's prescription.defineStack:STACK_SCHEMA_INVALID/ 422 atflows.1.nodes.1.config.objectName.ObjectStackDefinitionSchema(the stack parseobjectstack validateruns) refuses atflows.0.nodes.1.config.objectName. The registeredflowtype 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 acreate_recordnode onsys_metadatait gave exit 1,"code": "STACK_SCHEMA_INVALID", and an error namingflows.0.nodes.1.config.objectNameand 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 fromFlowSchema.parseinsidecanonicalizeStoredFlow(engine.ts:4346), whichregisterFlow(engine.ts:4368) calls.objectName({record.target},{target}, and the envelope{ dialect: 'cel', source: ... }) passes at save, and the judge returns nothing for it.get_recordon either family table passes. The refused set equals the predicate's by exact name (SYS_METADATA,sys_metadata_draft,sys_metadatawith a leading space,sys_meta,metadataall pass).Ablation of the spec pins (one-shot, not kept)
At
f7d1216a8f, with the implementation committed,scripts/ablation-replace.mjsreplaced 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 blob82a173479etoa832964b45. No dist rebuild was needed: the tests reach the judge through relativesrcimports. Predicted 17 red / 29 green over the two pin files; observedTests 17 failed | 29 passed (46). Restore: the tool reported blob after restore equal to the HEAD blob (82a173479e) andgit diff HEADempty. The script's own EXIT/INT/TERM trap, usinggit 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 at8f5adb6ad6(before the stack-parse door test existed) read 16 / 29, as predicted.Verification
Spec-side readings at
f7d1216a8f.packages/spechas not changed since; round 2 touched only theservice-automationtest 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-typecheckis OK, andtsc -p tsconfig.test.json --listFileslists 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.pnpm lintitself 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 noparserOptions.projectand 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.dispatch-gates --commands(no paths), derived fresh at this head: 94 families, the round-1 92 pluscheck-tenant-audit-censusand its self-test. All 94 were run fresh on this head, each exited 0, and--ranwith exit codes recorded reads 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN.origin/mainwas merged twice throughscripts/pm/os-regen-merge.sh(no rebase): once for #21668 / #21673, which landedregistry.tschanges, and once for7d0781482d. Each merge regenerated and re-checked the artefacts afterwards. The sibling entries are all present (each id counted the same onorigin/mainand here), and this branch'sregistry.tsdelta againstmainis +61 / -0.Acceptance notes
flowNodeConfigRefusalswalk inpackages/spec/src/automation/flow.zod.tsstill 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 inflow-node-config-refusals.tsstates all three arms.packages/lint/src/validate-expressions.tsdescribes its call into the judge as "a key its contract requires, left out, and adecisionbranch list it cannot read". That list is now incomplete, not false: the call also emits the new refusal, aserror.service-automation'sstoredMetadataWriteRefusaland the runtime body boundary's privatePRESCRIPTIONcan importSTORED_METADATA_BODY_PRESCRIPTIONfrom@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_metadatawith flows found none that this change falsifies.Generated by Claude Code