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
Conversation
…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>
…aluate-refusal Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Fixes #21623
Clause-②: no
What this changes
A flow's
get_recordnode 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 itsfilteragainst 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.tsgainsstoredMetadataFilterRefusal(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:storedMetadataBodyPredicateRefusalfirst, thenstoredMetadataHashEvaluateRefusal. 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 isINVALID_FIELD, and no code is minted.get_recordexecutor 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, andfindwhenlimitis above 1).{ where: filter }, which is the interpolated filter in the slot both engine reads pass it in. The collector readswhereand thefilteralias the engine accepts, so the node's key is covered under either spelling.{ filterFields, sortFields }to the body refusal and{ groupBy, filterFields, sortFields }to the hash refusal. The node's config declares no sort and no grouping (strictGetRecordConfigSchema:objectName,filter,fields,limit,outputVariable), so both are fed{ filterFields }only.metadata-protocoledit, nopackages/specedit and no write-node edit. There is no new dependency edge either: the three functions come from the@objectstack/metadata-protocoldependency 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_recorddoes to the run (measured)errorClass: 'guard'), so afaultedge does not route it.success: false,status: 'failed', with no declared output. No node downstream of the refused node runs, and the family engine read never runs.code, sinceAutomationResult.codebelongs to the trigger and resume refusals. The step log's failure code is the engine'sNODE_FAILURE, as for every failed node.{$error.code}, which readsINVALID_FIELD. Atry_catchcatch region reads it there and on its own error variable.Reproduction on
mainbefore the fix (by class)The composition is a kernel with
ObjectQLPlugin, the realAutomationServicePluginanddriver-sqlon 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 withINVALID_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.tshas 16 cases on the same composition:INVALID_FIELD/ 400.runAs: 'system'andrunAs: 'user', crossed with thefindOneandfindbranches, 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 thetry_catcherror variable) must equal the door's code for the same filter.{ $field }comparand that reads the body. Each is refused with the door's code.faultedge fails the run, and the handler never runs.Ablation
The fix was committed first. The ablation was run at
b92c808613and again atd06a84e67a, 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).scripts/ablation-replace.mjsreplaced the executor'sif (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 relativesrcimport, so nodistrebuild or preflight applies.git diff HEADempty. A bash trap (git checkout HEAD --on the absolute path) re-confirmed the HEAD blob, with 0 diff lines.git status --porcelainshowed 0 lines, the marker grep 0 and the anchor grep 1.Verification
The final readings are at HEAD
d06a84e67a, which mergedorigin/mainat5b5e83f446and then rebuilt the tree (turbo run build, 72 of 72 tasks). Every heavy run went throughscripts/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, andcheck:test-typecheckis OK.tsc --listFilesshows the new pin file in bothtsconfig.jsonandtsconfig.test.json.metadata-protocolfamily 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).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths) atd06a84e67aderives 64 commands (30 pnpm, 34 direct node). All 64 were run there, and each exited 0.--ranreconciles them as 64 derived, 64 run, 0 NOT-MEASURED and 0 UNRUN (exit 0). An earlier pass atb92c808613, before the merge, derived the same 64. There,check:dual-build-cjs-loadsexited 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-bytesexited 0, and a control-byte scan of the 3 changed files found none. No turbo-driven gate left anAGENTS.mdblock, and the tree was clean after each pass.pnpm lintitself is CI's). The population, read from eslint's own config, is 2 of 3 changed paths:crud-nodes.tsand the pin file. For the changeset, eslint answers "no matching configuration". The--format jsoncount is 0 errors and 0 warnings on those 2. Invariance:eslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move the verdict on an untouched file.Acceptance notes
storedMetadataFilterRefusaltakes the node type, so it can serve the write nodes' filters too. That would matter if security(automation): flow create_record and update_record nodes write the stored-metadata family tables directly, outside the metadata protocol (measured; the unruled neighbour of #21519) #21624's ruling were overturned: security(automation): flow create_record and update_record nodes write the stored-metadata family tables directly, outside the metadata protocol (measured; the unruled neighbour of #21519) #21624 refuses family targets oncreate_record/update_record/delete_recordoutright, which also closes their filter exit. It is not wired there, and security(automation): flow create_record and update_record nodes write the stored-metadata family tables directly, outside the metadata protocol (measured; the unruled neighbour of #21519) #21624 remains open.try_catchregion still catches this refusal. Atry_catchregion catches a failing region whatever its class, which is pre-existing behaviour for every guard refusal. The read never ran, so the answer is the same refusal whatever the stored value is, and the oracle stays closed.NodeExecutionResult.codedoc comment inpackages/services/service-automation/src/engine.tssayscreate_recordis the only executor that setscodetoday.get_recordnow sets it for this refusal. The file is outside this claim's surface, so the comment is not edited here. Carrier: none..changeset/21519-flow-read-node-family-serve.mdis unreleased and says the node'sfilterbehaves as before. For a family read, it no longer does. This PR does not edit a changeset it did not add. Both changesets compile into the same release, and this PR's own changeset states the change. Carrier: none.content/docs/automation/flows.mdx, the guard-refusal callout's "In practice that is …" list does not name this refusal. That leaves the list incomplete, not false, so it is not edited.content/docs/**(outsidereleases/) orskills/**states how theget_recordfilter treats stored metadata, so no sentence there is made false.Generated by Claude Code