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
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