Skip to content

fix(service-automation): flow write nodes refuse a stored-metadata family target (#21624) - #21649

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21624-flow-write-nodes-refuse-family
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21624-flow-write-nodes-refuse-family

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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_record and delete_record nodes no longer write the stored-metadata family (the current metadata table and its version history, judged by the family's own predicate isStoredMetadataBodyObject from @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.ts gains storedMetadataWriteRefusal(nodeType, objectName). For a family target it returns a guard refusal (refuseNode, the node's existing channel) that carries the standard catalog's PERMISSION_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 gets undefined.
  • Each of the three write executors calls it right after the config parse and the objectName check. That is before the filter is interpolated or the erased-condition guard runs, before fields is 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: their filter is never run against a family table.
  • The name judged is the exact objectName string the executor hands the engine. The write executors do not interpolate objectName, which was measured: a {token} target reaches the engine as the literal template.
  • ⛔ The node does not route the write through the protocol. That would be a second write path into the family. Nothing is copied from another package, and there is no new dependency edge.
  • Rider (declared in the claim): the NodeExecutionResult.code docblock in engine.ts named 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 no metadata-protocol, packages/spec or lint edit.

Census, taken before any edit

The census asked which shipped flow has a create_record, update_record or delete_record node 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 the objectName / object key 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.

Root (tree) Write nodes found Family targets Non-literal targets
objectstack packages/**, non-test (96b0e3108a) 24 0 7, all of them the executors' own descriptor forms and one README fragment, none a flow
objectstack examples/**, non-test 9 (9 of the 9 write-node sites git grep finds) 0 0
objectstack skills/** 3 0 0
objectstack content/docs/** 15 0 10 (doc fragments)
hotcrm, shallow clone at 9466837 38 0 0
test files (objectstack packages and examples; hotcrm) 182; 9 0; 0 39; 5, read by hand, none a family name
  • Platform flows: no platform package ships a flow with any write node. The 24 package hits are node descriptors, conversion-layer fixtures, the CLI's explain text and changelog examples. The production registerFlow callers are the flow loader and the /automation write doors; neither builds a flow of its own.
  • Interpolated targets: the executors read objectName raw 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.
  • Positive controls: a synthetic fixture with one delete_record and one update_record aimed at the two family tables gave 2 of 2 family hits. The same scanner, run over get_record in 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 in metadata-core.
  • Not measured: cloud is not in the census triage named. Tenant flows stored in deployments are deployment data and are not in any checkout.

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 main before the fix (by class)

The composition was ObjectKernel, ObjectQLPlugin, the real AutomationServicePlugin, and driver-sql on better-sqlite3 :memory:. Every case was run without the security plugin and with the real SecurityPlugin (the default permission sets). No stored value is recorded here.

  • Without the security plugin: all three nodes, on both family tables, under both runAs: 'system' and runAs: 'user', changed the table (12 of 12). That includes delete_record, which was not measured on the card.
  • With the security plugin:
    • 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.
    • So in a secured composition the reach is the system identity, and it does not depend on grants.
  • The write nodes' evaluate exit: an 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.
  • A target supplied by a variable (objectName: '{record.target}') is not interpolated: the engine was asked for the literal template, answered "not found", and the family table was unchanged.
  • Spelling variants (upper case, mixed case, leading or trailing space): 4 variants × 3 nodes × 2 compositions all answered "not found". The registry resolves exact names, so the exact-name predicate leaves no variant reach.

The code: why PERMISSION_DENIED

The dispatch asked which code the generic data door answers for a direct family write, so it could be reused. Two answers were measured:

  • REST /data/:object write verbs on either table answer 405 OBJECT_API_METHOD_NOT_ALLOWED. That answer comes from apiAccessDenialFromEnable over the tables' apiMethods: ['get', 'list']. Measured through apiExposureDenialReason, the spec helper that function wraps: create, update and delete give method-not-allowed.
  • The door's in-process write path (createData / updateData / deleteData) answers a non-platform principal with PERMISSION_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, and PERMISSION_DENIED is 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:

  • The /meta save door (Studio, REST /meta, MCP authoring) runs saveMetaItem, 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-type FlowSchema safeParse. Finally, the runtime authoring gate in metadata-protocol runs the @objectstack/lint/runtime rules.
  • registerFlow is not on that path. The engine registers the stored row afterwards, on the publish rebind.
  • The judge every door shares is FlowSchema.parse in packages/spec. flow-node-config-refusals.ts describes itself as "the one judge FlowSchema.parse, AutomationEngine.registerFlow (which parses first) and objectstack validate share".
  • Precedent: the same ruling's hook half put its save-time refusal in the spec's 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.

  • Refused, without the security plugin: 6 cases (each node × each identity), each over both family tables. In every case:
    • the run fails, and the node downstream does not run;
    • the engine's insert / update / delete is never called on a family table, and the table snapshot is unchanged;
    • the refusal names the metadata protocol;
    • a flow reads PERMISSION_DENIED on {$error.code} and on a try_catch region's error variable.
  • Refused, with the security plugin: 3 cases, each node over both tables under runAs: 'system', under runAs: 'user' as a member, and under runAs: 'user' as a platform administrator. The same assertions apply.
  • Control: the data door answers a member's and an administrator's create, update and delete on the family with PERMISSION_DENIED / 403.
  • The evaluate exit (2 cases): an update_record and a delete_record whose 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.
  • Guard routing: with a fault edge on the node, the run still fails and the handler never runs, for all three nodes.
  • A target supplied by a variable (2 cases, both identities): the family table is unchanged and the engine's write verb is never called on it.
  • Non-family control (2 cases, both identities): an ordinary object is created, updated and deleted exactly as before.

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.

  • Mutation: scripts/ablation-replace.mjs replaced the family test at the head of storedMetadataWriteRefusal with an always-undefined return 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 relative src import, so no dist rebuild or preflight applies.
  • Result: 12 failed and 5 passed (17), matching the prediction. On every red case, the first assertion to fail was the run's success flag: the write ran and the run succeeded.
  • Restore: the tool reported the blob after restore equal to the HEAD blob and git diff HEAD empty. A bash trap (git checkout HEAD -- on the absolute path, then a blob comparison) re-confirmed it with 0 diff lines. git status --porcelain showed 0 lines and the marker grep 0. The re-run gave 17 passed (17).
  • The ablation was run at 1e7d538944, at 233d3206e7 and at afbc55b764 (the final head), with identical readings each time.

Verification

Every reading is at HEAD afbc55b764. That head merged origin/main at 15fe567c9c (three commits, none in this package) and then rebuilt the tree (turbo run build excluding docs, 72 of 72 tasks). Every heavy run went through scripts/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, and check:test-typecheck is OK. tsc --listFiles shows the new pin file in both tsconfig.json and tsconfig.test.json.
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) derives 65 commands at afbc55b764 (31 pnpm and 34 direct node), with no stale-tree warning.
    • All 65 were run there, and each exited 0. --ran reconciles them: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN (exit 0).
    • check:dual-build-cjs-loads measured 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-erasure was 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 in 233d3206e7, and the gate holds at 236.
    • That first derivation was 2 commits behind origin/main (a workflow file and the SDUI manifest record changed). After the merge, the same 65 commands derive.
    • Outside the derived set, and so NOT MEASURED locally, belonging to CI: six workflow-valued families (the three shard attestations, the issue-citation census and the two test-completeness checks), the five CI-shell jobs these paths schedule (Test Core, Temporal Conformance, the Dogfood Regression Gate, Dogfood Verify CLI and Build Core) and the type-check lanes.
    • check:nul-bytes exited 0. No turbo-driven gate left an AGENTS.md block, and git status --porcelain was empty after each pass.
  • Lint, as a proven narrowing (pnpm lint itself is CI's):
    • Population, read from eslint's own config: 3 of the 4 changed paths are linted (crud-nodes.ts, engine.ts and the pin file). For the changeset, eslint answers "no matching configuration".
    • Count, from --format json with --no-inline-config: 0 errors and 0 warnings on the 3 linted files.
    • Invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project, no typed rules), so this diff cannot move the verdict on an untouched file.
  • A control-byte scan of the 4 changed files found none.

Docs

content/docs/** (outside releases/) and skills/** 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:

  • The guard callout in content/docs/automation/flows.mdx has an "In practice that is …" list. It does not name this refusal (nor the read-node one), so the list is incomplete, not false.
  • The {$error.code} row there says "e.g. create_record's DUPLICATE_RECORD", which stays true.

Acceptance notes

  • The prescription sentence now has a third copy. The runtime's body boundary and the spec's hook refusal each hold it in a module-private constant, and this node words it for a flow. No shared constant exists that this package can import. Carrier: none.
  • An unreleased changeset sentence is now incomplete (not edited). .changeset/21623-flow-read-node-evaluate-refusal.md says "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.
  • In a secured composition, before this change, the middleware's refusal of a user-identity family write was routable and carried no code. It is now the node's guard refusal with a code. That was measured on the base and is not filed separately, since this change supersedes it.
  • registerFlow was deliberately not given a second static check. It parses FlowSchema first, so the save-time judge, wherever the seat places it, reaches it without a copy here.

Generated by Claude Code

claude added 3 commits October 3, 2026 23:08
…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>
@github-actions github-actions Bot added the size/m label Oct 4, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 17 documentable anchor(s).

34 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 15fe567c9c74e088684944094aa686b9bd3b386c.

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

What this run could not see
  • 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 — 6 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 15fe567c9c74e088684944094aa686b9bd3b386c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6e7baedd5226b3b0902e08fbb62a353c51f10168 — the merge of head afbc55b76463ee1f894193d551d1565c6e27dc76 into base 15fe567c9c74e088684944094aa686b9bd3b386c, 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 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

⚠️ 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 15fe567c9c74e088684944094aa686b9bd3b386c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 4, 2026 00:26
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 4, 2026 00:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit f40bb32 Oct 4, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21624-flow-write-nodes-refuse-family branch October 4, 2026 00:58
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
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 size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants