Commit 96b0e31
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 #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 #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
- **Not wired into the write nodes.** `storedMetadataFilterRefusal`
takes the node type, so it can serve the write nodes' filters too. That
would matter if #21624's ruling were overturned: #21624 refuses family
targets on `create_record` / `update_record` / `delete_record` outright,
which also closes their filter exit. It is not wired there, and #21624
remains open.
- **A `try_catch` region still catches this refusal.** A `try_catch`
region 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.
- **A code comment this makes false (not edited).** The
`NodeExecutionResult.code` doc comment in
`packages/services/service-automation/src/engine.ts` says
`create_record` is the only executor that sets `code` today.
`get_record` now sets it for this refusal. The file is outside this
claim's surface, so the comment is not edited here. Carrier: none.
- **An unreleased changeset sentence this makes false (not edited).**
`.changeset/21519-flow-read-node-family-serve.md` is unreleased and says
the node's `filter` behaves 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.
- **A docs list that is now incomplete (not edited).** In
`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.
- **Docs grep.** Nothing in `content/docs/**` (outside `releases/`) or
`skills/**` states how the `get_record` filter treats stored metadata,
so no sentence there is made false.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1e4ae08 commit 96b0e31
3 files changed
Lines changed: 453 additions & 0 deletions
File tree
- .changeset
- packages/services/service-automation/src/builtin
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
Lines changed: 61 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| 23 | + | |
23 | 24 | | |
24 | 25 | | |
25 | 26 | | |
| 27 | + | |
26 | 28 | | |
| 29 | + | |
27 | 30 | | |
28 | 31 | | |
29 | 32 | | |
| |||
270 | 273 | | |
271 | 274 | | |
272 | 275 | | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
273 | 326 | | |
274 | 327 | | |
275 | 328 | | |
| |||
353 | 406 | | |
354 | 407 | | |
355 | 408 | | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
356 | 417 | | |
357 | 418 | | |
358 | 419 | | |
| |||
0 commit comments