Skip to content

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

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21623-flow-read-node-evaluate-refusal
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21623-flow-read-node-evaluate-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21623
Clause-②: no

What this changes

A flow's get_record node no longer evaluates the stored-metadata family's body or content hash. PR #21621 (#21519) closed the node's serve and copy exits: a family read is served with the body projected and the hash keyed. It did not close the evaluate exit. The node still ran its filter against the stored values as written, so whether a row came back answered a predicate over the body column or a content-hash column. That is the predicate-oracle shape the family's refusals name.

The fix is one bounded call at the node, built only from the data door's own functions (triage ruling 5972899653):

  • crud-nodes.ts gains storedMetadataFilterRefusal(nodeType, objectName, query). For a family object (isStoredMetadataBodyObject), it collects the columns the filter reads with the family's one collector, collectStoredMetadataFilterFields. It then asks the door's two evaluate refusals in the door's order: storedMetadataBodyPredicateRefusal first, then storedMetadataHashEvaluateRefusal. A refusal comes back as a guard refusal (refuseNode, the node's existing channel) that carries the door's own error code, read off the door's refusal rather than spelled again. The code is INVALID_FIELD, and no code is minted.
  • The get_record executor calls it after the filter is interpolated and the erased-condition guard has run. It runs before the data engine is looked up, so it runs before either engine read (findOne, and find when limit is above 1).
  • Query shape. The collector is handed { where: filter }, which is the interpolated filter in the slot both engine reads pass it in. The collector reads where and the filter alias the engine accepts, so the node's key is covered under either spelling.
  • Option shape. The door passes { filterFields, sortFields } to the body refusal and { groupBy, filterFields, sortFields } to the hash refusal. The node's config declares no sort and no grouping (strict GetRecordConfigSchema: objectName, filter, fields, limit, outputVariable), so both are fed { filterFields } only.
  • Nothing is copied: there is no metadata-protocol edit, no packages/spec edit and no write-node edit. There is no new dependency edge either: the three functions come from the @objectstack/metadata-protocol dependency that PR fix(service-automation): a flow's get_record node serves the stored-metadata family the way the data door does (#21519) #21621 added.

What a refused get_record does to the run (measured)

  • The node fails as a guard (errorClass: 'guard'), so a fault edge does not route it.
  • The run answers success: false, status: 'failed', with no declared output. No node downstream of the refused node runs, and the family engine read never runs.
  • The run result itself carries no code, since AutomationResult.code belongs to the trigger and resume refusals. The step log's failure code is the engine's NODE_FAILURE, as for every failed node.
  • The door's code reaches the flow on {$error.code}, which reads INVALID_FIELD. A try_catch catch region reads it there and on its own error variable.

Reproduction on main before the fix (by class)

The composition is a kernel with ObjectQLPlugin, the real AutomationServicePlugin and driver-sql on better-sqlite3 :memory:. One family row was written with a synthetic credential in a withheld slot and its canonical content hash. The matrix covered both run identities, both node branches and both family tables. In each cell, a filter over the body column, or over the hash column, was run once with a value that matches the stored row and once with one that does not. 16 of 16 cells answered the matching filter with the row and the non-matching one with none. A body condition that a flow variable supplies ({ $and: '{record.conds}' }) gave the same reading under both identities (2 of 2). The data door refused both filter shapes with INVALID_FIELD / 400. After the fix, every cell is refused and the family engine read count is 0. No stored value is recorded here.

Pins

get-record-stored-metadata-filter-refusal.integration.test.ts has 16 cases on the same composition:

  • Control: the data door refuses each filter shape on both family tables with INVALID_FIELD / 400.
  • The matrix (8 cases): runAs: 'system' and runAs: 'user', crossed with the findOne and find branches, crossed with a body-column and a hash-column filter. Each case runs a matching filter and a non-matching one. Both must give a failed run, no output, no family engine read and no downstream write. The code a flow reads on {$error.code} (and on the try_catch error variable) must equal the door's code for the same filter.
  • The history table: its parent-hash column, its hash column, its change-note column (which can quote a hash), and a cross-field { $field } comparand that reads the body. Each is refused with the door's code.
  • Guard routing: a refused read with a fault edge fails the run, and the handler never runs.
  • Interpolation (both identities): a filter whose body condition list arrives through a flow variable is refused, judged after interpolation. So is a body condition whose value is a flow variable.
  • A scalar-column filter on a family read (both identities): it is served projected, with the door's keyed hash, as PR fix(service-automation): a flow's get_record node serves the stored-metadata family the way the data door does (#21519) #21621 serves it.
  • A non-family read is unchanged: on an ordinary object whose columns share the family's column names, the same filter shapes run and serve the row as stored.

Ablation

The fix was committed first. The ablation was run at b92c808613 and again at d06a84e67a, with identical readings. The direction was predicted before the run: with the refusal's effect removed, the 12 refused-shape cases go red, because the engine read runs and answers, and the 4 controls stay green (door control, two scalar-filter cases, non-family).

  • Mutation: scripts/ablation-replace.mjs replaced the executor's if (familyRefusal) return familyRefusal; with a no-op carrying a marker. The anchor count went from 1 to 0, the marker count 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: Tests 12 failed and 4 passed (16), matching the prediction. On every red case, the first assertion to fail was "the run must fail".
  • 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) re-confirmed the HEAD blob, with 0 diff lines. git status --porcelain showed 0 lines, the marker grep 0 and the anchor grep 1.

Verification

The final readings are at HEAD d06a84e67a, which merged origin/main at 5b5e83f446 and then rebuilt the tree (turbo run build, 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 168 passed (168), Tests 2078 passed (2078), 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.
  • The metadata-protocol family door tests, run as read-only controls (protocol.data-door-stored-content-hash, protocol.data-door-stored-metadata-filter-reads, protocol.data-door-stored-metadata-redaction, protocol.served-content-hash, stored-metadata-body-family.pin): Test Files 5 passed (5), Tests 122 passed (122).
  • Gates: running node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) at d06a84e67a derives 64 commands (30 pnpm, 34 direct node). All 64 were run there, and each exited 0. --ran reconciles them as 64 derived, 64 run, 0 NOT-MEASURED and 0 UNRUN (exit 0). An earlier pass at b92c808613, before the merge, derived the same 64. There, check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET, an unbuilt tree) and exited 0 once the tree was built. Six workflow-valued families print as NOT MEASURED in the derivation, so they belong to CI: the three shard attestations, the issue-citation census and the two test-completeness checks. The same holds for the CI-shell jobs this path set schedules: Test Core, Temporal Conformance, the Dogfood Regression Gate, Dogfood Verify CLI and Build Core. check:nul-bytes exited 0, and a control-byte scan of the 3 changed files found none. No turbo-driven gate left an AGENTS.md block, and the tree was clean after each pass.
  • Lint, as a proven narrowing (pnpm lint itself is CI's). The population, read from eslint's own config, is 2 of 3 changed paths: crud-nodes.ts and the pin file. For the changeset, eslint answers "no matching configuration". The --format json count is 0 errors and 0 warnings on those 2. 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.

Acceptance notes


Generated by Claude Code

claude added 4 commits October 3, 2026 21:05
…at evaluates the stored-metadata family's body or content hash, with the data door's own refusal

The node collects the interpolated filter's columns with the family's one
filter-field collector and asks the door's body and content-hash evaluate
refusals, in the door's order, before the engine read runs. The refusal is
a guard refusal carrying the door's own error code.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…on the stored-metadata family

Under both run identities and on both node branches, a body-column and a
hash-column filter on a family read are refused with the data door's code
before the engine read runs, whether or not they match; a variable-built
body filter is judged after interpolation. A scalar-column filter on a
family read is served projected, and a non-family read is unchanged.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…d-metadata family; pin the change-note column and the guard routing

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx (via get_record (literal, a string literal in registerCrudNodes))
  • content/docs/ai/agents.mdx (via get_record (literal, a string literal in registerCrudNodes))
  • content/docs/ai/connect-mcp.mdx (via get_record (literal, a string literal in registerCrudNodes))
  • content/docs/ai/index.mdx (via get_record (literal, a string literal in registerCrudNodes))
  • content/docs/ai/natural-language-queries.mdx (via get_record (literal, a string literal in registerCrudNodes))
  • content/docs/ai/tools.mdx (via get_record (literal, a string literal in registerCrudNodes))
  • content/docs/api/index.mdx (via get_record (literal, a string literal in registerCrudNodes))
  • content/docs/automation/flows.mdx (via get_record (literal, a string literal in registerCrudNodes))
  • content/docs/automation/jobs.mdx (via get_record (literal, a string literal in registerCrudNodes))
  • content/docs/getting-started/build-with-claude-code.mdx (via get_record (literal, a string literal in registerCrudNodes))
  • content/docs/kernel/runtime-services/examples.mdx (via get_record (literal, a string literal in registerCrudNodes))
  • content/docs/permissions/system-context.mdx (via registerCrudNodes (symbol, a top-level function))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via get_record (literal, a string literal in registerCrudNodes))
  • content/docs/releases/v17/17-5.mdx (via get_record (literal, a string literal in registerCrudNodes))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 5b5e83f446bde0bf6e13db304b9f07f115635704 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7b939fbf68e0ea02e058f0a5a1f82055a3dd8636 — the merge of head d06a84e67a00df76e9c4f834fb99e6f31aca482c into base 5b5e83f446bde0bf6e13db304b9f07f115635704, 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 7b939fbf68e0ea02e058f0a5a1f82055a3dd8636 && git checkout 7b939fbf68e0ea02e058f0a5a1f82055a3dd8636
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5b5e83f446bde0bf6e13db304b9f07f115635704 d06a84e67a00df76e9c4f834fb99e6f31aca482c && git checkout -B drift-repro 5b5e83f446bde0bf6e13db304b9f07f115635704 && git merge --no-ff d06a84e67a00df76e9c4f834fb99e6f31aca482c

node scripts/docs-audit/affected-docs.mjs --json 5b5e83f446bde0bf6e13db304b9f07f115635704

⚠️ 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 5b5e83f446bde0bf6e13db304b9f07f115635704 → 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 3, 2026 22:27
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 22:27
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 96b0e31 Oct 3, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21623-flow-read-node-evaluate-refusal branch October 3, 2026 22:50
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…mily target (objectstack-ai#21624) (objectstack-ai#21649)

Part of objectstack-ai#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
objectstack-ai#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 objectstack-ai#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 objectstack-ai#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](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

2 participants