Skip to content

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

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21654-flow-family-write-refused
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21654-flow-family-write-refused

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

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 [Decision] security(objectql): may a hook's handler name 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.
  • 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

@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 24 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/kernel.json, packages/spec/export-origins/kernel.json, packages/spec/src/kernel/metadata-type-redaction.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/kernel.json, packages/spec/export-origins/kernel.json, packages/spec/src/kernel/metadata-type-redaction.ts) — pages documenting those are invisible to this run
  • 2 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 37 pages), objectName (literal, 37 pages)
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 223e4990b432e5e3467eead02eb42e28b2916091 — the merge of head a9d2d5d453d51b67cedda80947c7b3e4caedd490 into base 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

2 participants