From d66868de0772b089b708f3af0a1fbb5255f57488 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 21:05:15 +0000 Subject: [PATCH 1/3] fix(service-automation): the flow get_record node refuses a filter that 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 --- .../src/builtin/crud-nodes.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index ba0f9d99b47..f1d17158633 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -20,10 +20,13 @@ import type { DroppedFieldsEvent } from '@objectstack/spec/data'; import { StandardErrorCode } from '@objectstack/spec/api'; import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; import { + collectStoredMetadataFilterFields, ephemeralStoredHashDigest, redactStoredMetadataRows, serveStoredMetadataHashColumnRows, + storedMetadataBodyPredicateRefusal, storedMetadataBodyProjection, + storedMetadataHashEvaluateRefusal, type StoredHashDigest, } from '@objectstack/metadata-protocol'; import type { AutomationEngine } from '../engine.js'; @@ -270,6 +273,56 @@ async function serveFamilyRead( return answer; } +/** + * [#21623] Refuse a node whose filter EVALUATES the stored-metadata family's + * body or content hash, the way the generic data door refuses the same filter. + * + * {@link serveFamilyRead} closes the node's serve and copy exits; this closes + * its evaluate exit. A filter over the stored body column, or over a stored + * content-hash column, is evaluated against the stored values row by row, so + * whether a row comes back answers the predicate even though the row itself + * is served projected: a guessed prefix of withheld credential material, or a + * guessed hash, returns the row exactly when it is right (the predicate oracle + * the family's refusals name). Under `runAs: 'system'` the engine reads + * elevated and cannot tell this read from the platform's own internal + * readers, so the rule is applied here, before the engine is asked. + * + * Built only from the door's own functions (`@objectstack/metadata-protocol`), + * in the door's own order, never a copy: + * - the columns the filter reads come from the family's ONE filter-field + * collector (`collectStoredMetadataFilterFields`): every key's head and + * every cross-field `{ $field }` comparand, at any depth; + * - the body refusal (`storedMetadataBodyPredicateRefusal`) is asked first, + * then the content-hash refusal (`storedMetadataHashEvaluateRefusal`). + * + * `query` is the option bag the node hands the engine, so the collector reads + * exactly the filter the engine would run: the INTERPOLATED one, since a + * `{token}` can resolve to a whole condition (a `$and` list, a comparand) whose + * columns the authored template does not show. The node configs declare no + * sort and no grouping, so the refusals are fed filter fields only. + * + * The answer is a guard refusal ({@link refuseNode}: the metadata is wrong, + * and re-running it unchanged never succeeds) carrying the door's own error + * code, read off the door's refusal rather than spelled again. `undefined` + * outside the family ({@link isStoredMetadataBodyObject}) and for a filter that + * reads neither column. + */ +function storedMetadataFilterRefusal( + nodeType: string, + objectName: string, + query: { where: Record }, +): (ReturnType & { code: string }) | undefined { + if (!isStoredMetadataBodyObject(objectName)) return undefined; + const filterFields = collectStoredMetadataFilterFields(objectName, query); + const refuse = (refusal: Error) => + ({ ...refuseNode(`${nodeType}: ${refusal.message}`), code: (refusal as Error & { code: string }).code }); + const bodyPredicateRefusal = storedMetadataBodyPredicateRefusal(objectName, { filterFields }); + if (bodyPredicateRefusal) return refuse(bodyPredicateRefusal); + const hashEvaluateRefusal = storedMetadataHashEvaluateRefusal(objectName, { filterFields }); + if (hashEvaluateRefusal) return refuse(hashEvaluateRefusal); + return undefined; +} + /** * CRUD built-in nodes — `get_record` / `create_record` / `update_record` / * `delete_record`, wired to the runtime data layer (ObjectQL / IDataEngine). @@ -353,6 +406,14 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): const limit = cfg.limit; const outputVariable = cfg.outputVariable; + // [#21623] A filter that evaluates the stored-metadata family's + // body or content hash is refused before the engine is asked, + // with the data door's own code, under either run identity. It + // reads the interpolated filter, in the `where` slot both + // engine reads below hand it in. + const familyRefusal = storedMetadataFilterRefusal('get_record', objectName, { where: filter }); + if (familyRefusal) return familyRefusal; + const data = getData(); if (!data) { ctx.logger.warn(`[get_record] no data engine; skipping ${objectName}`); From 1d859cfc2fe1067651be8b2d901ed4a53265a65b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 21:08:06 +0000 Subject: [PATCH 2/3] test(service-automation): pin the get_record node's evaluate refusal 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 --- ...etadata-filter-refusal.integration.test.ts | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts diff --git a/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts new file mode 100644 index 00000000000..6fc20a545bc --- /dev/null +++ b/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts @@ -0,0 +1,363 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21623] A flow's `get_record` node does not EVALUATE the stored-metadata-body + * family (`sys_metadata` / `sys_metadata_history`): a filter that reads the + * stored body column or a stored content-hash column is refused before the + * engine read runs, with the generic data door's own refusal and code. + * + * #21519 closed the node's serve and copy exits (the body projected, the hash + * keyed). This closes the evaluate exit: the served row is projected, but + * whether a row comes back answers the filter, so a filter over the body or a + * hash is a predicate oracle. Each refused case is run twice, with a filter + * that matches the stored row and with one that does not, and both answers + * must be the same refusal: the answer may not depend on the stored value. + * + * What a refused case pins, under BOTH run identities (`runAs: 'system'`, + * which reads elevated, and `runAs: 'user'`) and on both node branches (one + * row through `findOne`; a row list through `find`, `limit > 1`): + * - the run fails (`status: 'failed'`) and declares no output; + * - the engine read of the family table never runs; + * - nothing downstream runs: the record the flow would write is absent; + * - the failure carries the data door's code for the same filter, judged + * against the door's own answer (the control), as a flow reads it on + * `{$error.code}` inside a `try_catch` catch region. + * + * The filter the node judges is the INTERPOLATED one, the filter the engine + * would run: a `{token}` that resolves to a whole condition list is refused + * when the resolved list reads the body. + * + * Controls: the data door refuses the same filters; a scalar-column filter on + * a family table is served projected, as #21519 serves it; and an ordinary + * object whose columns share the family's column names is filtered and served + * exactly as stored. + * + * Composition: the real stack `get-record-stored-metadata-family.integration.test.ts` + * boots (`ObjectKernel`, `ObjectQLPlugin`, `driver-sql` on better-sqlite3 + * `:memory:`, the real `AutomationServicePlugin`). The credential is a + * synthetic sentinel in a credential slot the datasource redactor withholds. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { hashSpec } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { AutomationServicePlugin } from '../plugin.js'; +import type { AutomationEngine, NodeExecutor } from '../engine.js'; + +const SYS = { isSystem: true } as const; +const KEYED = /^hmac-sha256:[0-9a-f]{64}$/; +const FAMILY = ['sys_metadata', 'sys_metadata_history'] as const; + +/** The synthetic credential, in the slot the datasource redactor withholds (turso's `encryptionKey`). */ +const SENTINEL = 'flow-filter-refusal-sentinel-91f3'; +const DS_NAME = 'pin_filter_ds'; +const DS_URL = 'libsql://pin-filter.example.invalid'; +const DS_BODY = { name: DS_NAME, label: 'Pin filter DS', driver: 'turso', config: { url: DS_URL, encryptionKey: SENTINEL } }; +const STORED_HASH = hashSpec(DS_BODY); +/** A well-formed hash that is not the stored one. */ +const OTHER_HASH = hashSpec({ ...DS_BODY, label: 'another body' }); + +/** An ordinary object a flow would copy what it read into, and the non-family control. */ +const COPY_OBJECT = { + name: 'pin_filter_copy', + label: 'Pin filter copy', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + metadata: { name: 'metadata', label: 'Metadata', type: 'textarea' }, + checksum: { name: 'checksum', label: 'Checksum', type: 'text' }, + }, +}; + +type RunAs = 'system' | 'user'; +type Branch = 'one' | 'list'; +type Filter = Record; + +/** + * Each refused filter shape, as a pair: `match` is satisfied by the stored row, + * `miss` is not. Before the fix the node answered the row for `match` and none + * for `miss`: the oracle these cases close. + */ +const REFUSED_SHAPES: ReadonlyArray<{ label: string; object: string; match: Filter; miss: Filter }> = [ + { + label: 'a body-column filter', + object: 'sys_metadata', + match: { metadata: { $contains: SENTINEL.slice(0, 12) } }, + miss: { metadata: { $contains: 'no-such-credential-prefix' } }, + }, + { + label: 'a hash-column filter', + object: 'sys_metadata', + match: { checksum: STORED_HASH }, + miss: { checksum: OTHER_HASH }, + }, +]; + +/** The trigger a user-identity run needs: a real acting user. */ +const TRIGGER = { userId: 'usr_flow_filter', tenantId: 'org_1', positions: [] as string[], permissions: [] as string[] }; + +/** + * start → get_record(object, filter) → create_record(copy) → end. `one` reads + * through `findOne` (no limit); `list` through `find` (`limit > 1`). + */ +function readThenCopyFlow(name: string, object: string, runAs: RunAs, branch: Branch, filter: Filter) { + return { + name, + label: name, + type: 'autolaunched', + runAs, + variables: [{ name: 'rec', type: 'object', isOutput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'read', + type: 'get_record', + label: 'Read', + config: { objectName: object, filter, outputVariable: 'rec', ...(branch === 'list' ? { limit: 5 } : {}) }, + }, + { id: 'copy', type: 'create_record', label: 'Copy', config: { objectName: COPY_OBJECT.name, fields: { title: name } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'read' }, + { id: 'e2', source: 'read', target: 'copy' }, + { id: 'e3', source: 'copy', target: 'end' }, + ], + }; +} + +describe("flow get_record refuses a filter that evaluates the stored-metadata family, with the data door's refusal (#21623)", () => { + let kernel: ObjectKernel; + let ql: ObjectQL; + let automation: AutomationEngine; + let door: ObjectStackProtocolImplementation; + let seq = 0; + /** What the capture node saw on `$error` and on the `try_catch`'s error variable. */ + const captured: Array<{ error?: { code?: string }; caught?: { code?: string } }> = []; + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'fatal' } }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); + await kernel.bootstrap(); + ql = kernel.getService('objectql'); + automation = kernel.getService('automation'); + + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.connect(); + ql.registerDriver(driver, true); + ql.registry.registerObject(COPY_OBJECT as any, 'pin-21623', 'pin-21623'); + await ql.syncSchemas(); + + const metadata = JSON.stringify(DS_BODY); + await ql.insert('sys_metadata', { + id: 'meta_pin_filter_ds', name: DS_NAME, type: 'datasource', scope: 'platform', state: 'active', + metadata, checksum: STORED_HASH, + }, { context: SYS }); + await ql.insert('sys_metadata_history', { + id: 'hist_pin_filter_ds', name: DS_NAME, type: 'datasource', version: 2, operation_type: 'update', + metadata, checksum: STORED_HASH, previous_checksum: OTHER_HASH, + }, { context: SYS }); + + automation.registerNodeExecutor({ + type: 'pin_capture_error', + async execute(_node, variables) { + captured.push({ + error: variables.get('$error') as { code?: string } | undefined, + caught: variables.get('caught') as { code?: string } | undefined, + }); + return { success: true }; + }, + } as NodeExecutor); + + door = new ObjectStackProtocolImplementation(ql as any); + }, 60_000); + + afterAll(async () => { + try { await kernel?.shutdown(); } catch { /* best-effort teardown */ } + }); + + /** The data door's answer to the same filter: the control each refusal is judged against. */ + async function doorRefusal(object: string, filter: Filter): Promise<{ code: string; status: number }> { + let thrown: any; + try { + await door.findData({ object, query: { where: filter } }); + } catch (err) { + thrown = err; + } + expect(thrown, `control: the data door must refuse this filter on ${object}`).toBeDefined(); + return { code: thrown.code, status: thrown.status }; + } + + /** + * Run `def` with the engine's reads watched. Returns the run result, the + * family-table reads the engine received while it ran, and whether the + * flow's downstream copy landed. + */ + async function runWatched(def: { name: string }, context: Record = {}) { + automation.registerFlow(def.name, def as any); + const find = vi.spyOn(ql, 'find'); + const findOne = vi.spyOn(ql, 'findOne'); + let res: any; + let familyReads = 0; + try { + res = await automation.execute(def.name, { ...TRIGGER, ...context }); + familyReads = [...find.mock.calls, ...findOne.mock.calls] + .filter(([object]) => (FAMILY as readonly string[]).includes(object as string)).length; + } finally { + find.mockRestore(); + findOne.mockRestore(); + } + const copy = await ql.findOne(COPY_OBJECT.name, { where: { title: def.name }, context: SYS }); + return { res, familyReads, copy }; + } + + /** Every refused run: failed, no output, no engine read, nothing downstream. */ + function expectRefusedRun(run: Awaited>, where: string) { + expect(run.res.success, `${where}: the run must fail`).toBe(false); + expect(run.res.status, `${where}: the run's status`).toBe('failed'); + expect(run.res.output?.rec, `${where}: the run declared an output`).toBeUndefined(); + expect(run.familyReads, `${where}: the engine read of the family table ran`).toBe(0); + expect(run.copy, `${where}: the node downstream of the refusal ran`).toBeFalsy(); + } + + /** + * The code the flow is handed for the refused node, read where a flow reads + * it: `{$error.code}` inside a `try_catch` catch region, and the region's + * own error variable. + */ + async function refusalCodeAsAFlowReadsIt(object: string, runAs: RunAs, filter: Filter): Promise<{ error?: string; caught?: string }> { + const name = `refusal_code_${seq++}`; + captured.length = 0; + automation.registerFlow(name, { + name, label: name, type: 'autolaunched', runAs, + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'guarded', type: 'try_catch', label: 'Guarded read', + config: { + errorVariable: 'caught', + try: { nodes: [{ id: 'read', type: 'get_record', label: 'Read', config: { objectName: object, filter, outputVariable: 'rec' } }], edges: [] }, + catch: { nodes: [{ id: 'capture', type: 'pin_capture_error', label: 'Capture' }], edges: [] }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'guarded' }, { id: 'e2', source: 'guarded', target: 'end' }], + } as any); + await automation.execute(name, { ...TRIGGER }); + expect(captured, 'the catch region must have run once').toHaveLength(1); + return { error: captured[0]!.error?.code, caught: captured[0]!.caught?.code }; + } + + it('control: the data door refuses each filter shape on both family tables with INVALID_FIELD / 400', async () => { + for (const shape of REFUSED_SHAPES) { + for (const object of FAMILY) { + for (const filter of [shape.match, shape.miss]) { + expect(await doorRefusal(object, filter), `${shape.label} on ${object}`).toEqual({ code: 'INVALID_FIELD', status: 400 }); + } + } + } + }); + + for (const runAs of ['system', 'user'] as const) { + for (const branch of ['one', 'list'] as const) { + for (const shape of REFUSED_SHAPES) { + it(`runAs:'${runAs}', ${branch === 'one' ? 'findOne' : 'find'} branch: ${shape.label} is refused before the engine read, whether or not it matches`, async () => { + for (const [side, filter] of [['match', shape.match], ['miss', shape.miss]] as const) { + const run = await runWatched(readThenCopyFlow(`refused_${seq++}`, shape.object, runAs, branch, filter)); + expectRefusedRun(run, `${shape.label} (${side})`); + } + const control = await doorRefusal(shape.object, shape.match); + expect(await refusalCodeAsAFlowReadsIt(shape.object, runAs, shape.match)) + .toEqual({ error: control.code, caught: control.code }); + }); + } + } + } + + it("the history table's hash columns and a cross-field comparand on the body are refused the same way", async () => { + const shapes: Array<[string, Filter]> = [ + ['the parent-hash column', { previous_checksum: OTHER_HASH }], + ['the hash column', { checksum: STORED_HASH }], + ['a comparand reading the body column', { name: { $ne: { $field: 'metadata' } } }], + ]; + for (const [label, filter] of shapes) { + const control = await doorRefusal('sys_metadata_history', filter); + const run = await runWatched(readThenCopyFlow(`history_${seq++}`, 'sys_metadata_history', 'system', 'one', filter)); + expectRefusedRun(run, label); + expect(await refusalCodeAsAFlowReadsIt('sys_metadata_history', 'system', filter), label) + .toEqual({ error: control.code, caught: control.code }); + } + }); + + for (const runAs of ['system', 'user'] as const) { + it(`runAs:'${runAs}': a filter whose body condition is built from a flow variable is judged after interpolation and refused`, async () => { + // The authored filter names no family column: the condition list arrives + // through `{record.conds}`. Only the interpolated filter (the one the + // engine would run) shows that it reads the body. + const authored: Filter = { $and: '{record.conds}' }; + for (const conds of [[{ metadata: { $contains: SENTINEL.slice(0, 12) } }], [{ metadata: { $contains: 'no-such-credential-prefix' } }]]) { + const run = await runWatched(readThenCopyFlow(`built_${seq++}`, 'sys_metadata', runAs, 'one', authored), { record: { conds } }); + expectRefusedRun(run, 'a variable-built body filter'); + } + // …and a body condition whose value is a flow variable. + const run = await runWatched( + readThenCopyFlow(`valued_${seq++}`, 'sys_metadata', runAs, 'list', { metadata: { $contains: '{record.guess}' } }), + { record: { guess: SENTINEL.slice(0, 12) } }, + ); + expectRefusedRun(run, 'a body filter with a variable value'); + }); + } + + for (const runAs of ['system', 'user'] as const) { + it(`runAs:'${runAs}': a scalar-column filter on a family read is served projected, with the door's keyed hash`, async () => { + const control: any = await door.findData({ object: 'sys_metadata', query: { where: { name: DS_NAME } } }); + expect(control.records).toHaveLength(1); + const controlRow = control.records[0]; + expect(controlRow.checksum).toMatch(KEYED); + + const run = await runWatched(readThenCopyFlow(`scalar_${seq++}`, 'sys_metadata', runAs, 'one', { name: DS_NAME, type: 'datasource' })); + expect(run.res.success, `run failed: ${JSON.stringify(run.res.error)}`).toBe(true); + expect(run.familyReads).toBeGreaterThan(0); + expect(run.copy, 'the node downstream of the read ran').toBeTruthy(); + const row = run.res.output.rec; + const text = JSON.stringify(row); + expect(text.includes(SENTINEL), 'the stored credential reached the output').toBe(false); + expect(text.includes(STORED_HASH), 'the stored content hash reached the output').toBe(false); + expect(JSON.parse(row.metadata)).toEqual(JSON.parse(controlRow.metadata)); + expect(JSON.parse(row.metadata).config.url).toBe(DS_URL); + expect(row.checksum).toBe(controlRow.checksum); + }); + } + + it('a non-family read is unchanged: a filter over columns that share the family\'s names runs and serves the row as stored', async () => { + await ql.insert(COPY_OBJECT.name, { title: 'plain_source', metadata: SENTINEL, checksum: STORED_HASH }, { context: SYS }); + for (const [filter, expected] of [ + [{ metadata: { $contains: SENTINEL.slice(0, 12) } }, 1], + [{ checksum: STORED_HASH }, 1], + [{ checksum: OTHER_HASH }, 0], + ] as const) { + const def = { + name: `plain_${seq++}`, label: 'plain', type: 'autolaunched', runAs: 'system', + variables: [{ name: 'rec', type: 'object', isOutput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'read', type: 'get_record', label: 'Read', config: { objectName: COPY_OBJECT.name, filter, outputVariable: 'rec', limit: 5 } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'read' }, { id: 'e2', source: 'read', target: 'end' }], + }; + automation.registerFlow(def.name, def as any); + const res: any = await automation.execute(def.name, { ...TRIGGER }); + expect(res.success, `run failed: ${JSON.stringify(res.error)}`).toBe(true); + expect(res.output.rec).toHaveLength(expected); + if (expected) { + expect(res.output.rec[0].metadata).toBe(SENTINEL); + expect(res.output.rec[0].checksum).toBe(STORED_HASH); + } + } + }); +}); From b92c8086134e35d8daf81ad4e4f7202c7dfdb4d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 21:09:16 +0000 Subject: [PATCH 3/3] chore(changeset): the get_record node's evaluate refusal on the stored-metadata family; pin the change-note column and the guard routing Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../21623-flow-read-node-evaluate-refusal.md | 13 +++++++++++++ ...metadata-filter-refusal.integration.test.ts | 18 +++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 .changeset/21623-flow-read-node-evaluate-refusal.md diff --git a/.changeset/21623-flow-read-node-evaluate-refusal.md b/.changeset/21623-flow-read-node-evaluate-refusal.md new file mode 100644 index 00000000000..a9e0a27a0ba --- /dev/null +++ b/.changeset/21623-flow-read-node-evaluate-refusal.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-automation': patch +--- + +fix(service-automation): a flow's `get_record` node refuses a filter that evaluates the stored-metadata tables' body or content hash, as the generic data door does (#21623) + +Clause-②: no + +The two stored-metadata tables (the current metadata bodies and their version history) hold each body as stored, credential material included, and content-hash columns computed over it. A flow's `get_record` node now serves those rows projected and keyed, but it still ran its `filter` against the stored values as written, under either run identity (`runAs: 'system'` and `runAs: 'user'`). A filter over the body column or a content-hash column was evaluated row by row, so whether a row came back answered the filter: a predicate over the withheld values. The generic data door refuses those filters before its query runs. + +**What changes.** When the node reads either table, it judges its filter the way the data door judges the same filter, before the data engine is asked, on both branches (one row, and a row list when `limit` is above 1). The columns the filter reads are collected after interpolation, so a condition that a `{token}` supplies is judged too. A filter that reads the body column, or a content-hash column (the history table's parent hash and change note included), refuses the node with the data door's own message and error code, `INVALID_FIELD`. The refusal is a guard failure: the run fails, nothing downstream of the node runs, and a `fault` edge does not route it. A `try_catch` catch region reads the code on `{$error.code}`. To read a stored-metadata row from a flow, filter by `name`, `type`, `state` or another scalar column. + +**What does not change.** A filter over scalar columns is served as before: the body projected and the hash keyed. Every other object is filtered and read exactly as before, including columns that share these names. The write nodes are unchanged. The node consumes the data door's own functions from `@objectstack/metadata-protocol` (`collectStoredMetadataFilterFields`, `storedMetadataBodyPredicateRefusal`, `storedMetadataHashEvaluateRefusal`) and keeps no copy of them. diff --git a/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts index 6fc20a545bc..a07e0e7037e 100644 --- a/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts +++ b/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts @@ -21,7 +21,9 @@ * - nothing downstream runs: the record the flow would write is absent; * - the failure carries the data door's code for the same filter, judged * against the door's own answer (the control), as a flow reads it on - * `{$error.code}` inside a `try_catch` catch region. + * `{$error.code}` inside a `try_catch` catch region; + * - the failure is a guard failure: a `fault` edge on the node does not + * route it. * * The filter the node judges is the INTERPOLATED one, the filter the engine * would run: a `{token}` that resolves to a whole condition list is refused @@ -282,6 +284,7 @@ describe("flow get_record refuses a filter that evaluates the stored-metadata fa const shapes: Array<[string, Filter]> = [ ['the parent-hash column', { previous_checksum: OTHER_HASH }], ['the hash column', { checksum: STORED_HASH }], + ['the change-note column, which can quote a hash', { change_note: { $contains: 'sha256' } }], ['a comparand reading the body column', { name: { $ne: { $field: 'metadata' } } }], ]; for (const [label, filter] of shapes) { @@ -293,6 +296,19 @@ describe("flow get_record refuses a filter that evaluates the stored-metadata fa } }); + it('the refusal is a guard failure: a fault edge on the node does not route it', async () => { + const def = readThenCopyFlow(`faulted_${seq++}`, 'sys_metadata', 'system', 'one', { checksum: STORED_HASH }); + def.nodes.splice(3, 0, { id: 'handler', type: 'pin_capture_error', label: 'Handler' } as any); + def.edges.push( + { id: 'e_fault', source: 'read', target: 'handler', type: 'fault' } as any, + { id: 'e4', source: 'handler', target: 'end' }, + ); + captured.length = 0; + const run = await runWatched(def); + expectRefusedRun(run, 'a refused read with a fault edge'); + expect(captured, 'the fault handler ran').toHaveLength(0); + }); + for (const runAs of ['system', 'user'] as const) { it(`runAs:'${runAs}': a filter whose body condition is built from a flow variable is judged after interpolation and refused`, async () => { // The authored filter names no family column: the condition list arrives