From d589cd4418ebea840c7bb39f99b0310e90400481 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 07:45:40 +0000 Subject: [PATCH] fix(service-automation): flow write-node family refusal ends on the shared prescription storedMetadataWriteRefusal keeps its node-specific lead and ends on STORED_METADATA_BODY_PRESCRIPTION from @objectstack/spec/kernel, the one sentence FlowSchema's save-time refusal of the same node ends on. The message-text pins read the imported constant instead of restating it. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- ...24-flow-write-nodes-shared-prescription.md | 13 +++++++++++++ .../src/builtin/crud-nodes.ts | 12 ++++++++---- ...etadata-family-refusal.integration.test.ts | 19 +++++++++++++++++-- 3 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 .changeset/21624-flow-write-nodes-shared-prescription.md diff --git a/.changeset/21624-flow-write-nodes-shared-prescription.md b/.changeset/21624-flow-write-nodes-shared-prescription.md new file mode 100644 index 00000000000..e6b72db1f3f --- /dev/null +++ b/.changeset/21624-flow-write-nodes-shared-prescription.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-automation': patch +--- + +fix(service-automation): a flow write node's refusal of a stored-metadata table ends on the same prescription sentence as the save-time refusal (#21624) + +Clause-②: no + +A flow `create_record`, `update_record` or `delete_record` node aimed at a stored-metadata table is refused twice: at save by `FlowSchema`, and at run time by the node itself, for a definition the parse never judged. Both refusals tell the author where a metadata change goes instead, and until now they said it in two spellings of one sentence: the run-time refusal named the elevation as `runAs: 'system'`, the save-time one as `runAs`, a system context. + +**What changes.** The run-time refusal's message keeps its lead (the node type, what it would have done and the table, and that the write was not run) and now ends on `STORED_METADATA_BODY_PRESCRIPTION`, imported from `@objectstack/spec/kernel`: the one sentence the save-time refusal and the hook refusal also end on. Its elevation clause now reads "Elevation (`runAs`, a system context) does not change this." + +**What does not change.** Which writes are refused, the refusal's `PERMISSION_DENIED` code, its guard classification (a `fault` edge does not route it) and every other object's writes are exactly as before. diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index b9bb559a0ca..86ed89180f2 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -18,7 +18,7 @@ import type { import type { AutomationContext, IDataEngine } from '@objectstack/spec/contracts'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; import { StandardErrorCode } from '@objectstack/spec/api'; -import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; +import { isStoredMetadataBodyObject, STORED_METADATA_BODY_PRESCRIPTION } from '@objectstack/spec/kernel'; import { collectStoredMetadataFilterFields, ephemeralStoredHashDigest, @@ -355,6 +355,11 @@ const STORED_METADATA_WRITE_VERB = { * a non-platform principal's write to these tables with in a secured * composition, and the code the body-write boundary for the same ruling * carries. No code is minted. `undefined` for any other object. + * + * Its message names the node, the verb and the table, then ends on the + * family's ONE prescription, `STORED_METADATA_BODY_PRESCRIPTION`, imported from + * `@objectstack/spec/kernel`: the sentence `FlowSchema`'s save-time refusal of + * the same node ends on, so the save and the run tell an author the same thing. */ function storedMetadataWriteRefusal( nodeType: keyof typeof STORED_METADATA_WRITE_VERB, @@ -364,9 +369,8 @@ function storedMetadataWriteRefusal( return { ...refuseNode( `${nodeType}: refusing to ${STORED_METADATA_WRITE_VERB[nodeType]} '${objectName}': it holds stored ` - + 'metadata, and a flow may not write it directly, so the write was not run. Change metadata through the ' - + 'metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its ' - + "provenance is recorded. Elevation (`runAs: 'system'`) does not change this.", + + 'metadata, and a flow may not write it directly, so the write was not run. ' + + STORED_METADATA_BODY_PRESCRIPTION, ), code: StandardErrorCode.enum.PERMISSION_DENIED, }; diff --git a/packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts b/packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts index b1f916a4c80..08fe0e58941 100644 --- a/packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts +++ b/packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts @@ -54,6 +54,7 @@ import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { STORED_METADATA_BODY_PRESCRIPTION } from '@objectstack/spec/kernel'; import { AutomationServicePlugin } from '../plugin.js'; import type { AutomationEngine, NodeExecutor } from '../engine.js'; @@ -78,6 +79,16 @@ const BODY_FRAGMENT = '"label":"Pin body"'; */ const STAND_IN_TARGET = 'pin_stand_in_target'; +/** + * The closing sentence of `text`, measured against the family's ONE + * prescription, which both the save-time and the run-time refusal end on. Read + * from the imported constant, never restated, so a later rewording of that one + * sentence moves these pins with it. + */ +function closingPrescriptionOf(text: string): string { + return text.slice(-STORED_METADATA_BODY_PRESCRIPTION.length); +} + /** One write node in a flow definition aimed at a family table, and where it sits. */ interface FamilyTarget { readonly path: string; @@ -264,7 +275,10 @@ function harness(ql: ObjectQL, automation: AutomationEngine) { (thrown!.issues ?? []).map((i) => ({ code: i.code, path: i.path.join('.') })), `${def.name}: the save-time refusal's issues`, ).toEqual(targets.map((t) => ({ code: 'custom', path: t.path }))); - for (const issue of thrown!.issues ?? []) expect(issue.message).toContain('the metadata protocol'); + for (const issue of thrown!.issues ?? []) { + expect(closingPrescriptionOf(issue.message), `${def.name}: the save-time refusal ends on the family's prescription`) + .toBe(STORED_METADATA_BODY_PRESCRIPTION); + } expect(await automation.getFlow(def.name), `${def.name}: a refused flow was registered`).toBeNull(); expect(await Promise.all(tables.map((object) => snapshot(object))), `${def.name}: the save-time refusal changed a table`) .toEqual(before); @@ -342,7 +356,8 @@ async function expectRefused(h: Harness, object: FamilyTable, nodeType: WriteNod const where = `${nodeType} on ${object}, runAs '${runAs}'`; expect(run.res.success, `${where}: the run must fail`).toBe(false); expect(run.res.status, `${where}: the run's status`).toBe('failed'); - expect(String(run.res.error), `${where}: the refusal names the metadata protocol`).toContain('the metadata protocol'); + expect(closingPrescriptionOf(String(run.res.error)), `${where}: the run-time refusal ends on the family's prescription`) + .toBe(STORED_METADATA_BODY_PRESCRIPTION); expect(run.downstreamRan, `${where}: the node downstream of the refusal ran`).toBe(false); expect(run.familyWrites, `${where}: the engine's write verb was called on the family table`).toBe(0); expect(await h.snapshot(object), `${where}: the family table changed`).toBe(before);