Repository navigation
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
|
…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>
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