Skip to content

Commit d589cd4

Browse files
committed
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 <noreply@anthropic.com>
1 parent a2aadab commit d589cd4

3 files changed

Lines changed: 38 additions & 6 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
**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."
12+
13+
**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.

‎packages/services/service-automation/src/builtin/crud-nodes.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type {
1818
import type { AutomationContext, IDataEngine } from '@objectstack/spec/contracts';
1919
import type { DroppedFieldsEvent } from '@objectstack/spec/data';
2020
import { StandardErrorCode } from '@objectstack/spec/api';
21-
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
21+
import { isStoredMetadataBodyObject, STORED_METADATA_BODY_PRESCRIPTION } from '@objectstack/spec/kernel';
2222
import {
2323
collectStoredMetadataFilterFields,
2424
ephemeralStoredHashDigest,
@@ -355,6 +355,11 @@ const STORED_METADATA_WRITE_VERB = {
355355
* a non-platform principal's write to these tables with in a secured
356356
* composition, and the code the body-write boundary for the same ruling
357357
* carries. No code is minted. `undefined` for any other object.
358+
*
359+
* Its message names the node, the verb and the table, then ends on the
360+
* family's ONE prescription, `STORED_METADATA_BODY_PRESCRIPTION`, imported from
361+
* `@objectstack/spec/kernel`: the sentence `FlowSchema`'s save-time refusal of
362+
* the same node ends on, so the save and the run tell an author the same thing.
358363
*/
359364
function storedMetadataWriteRefusal(
360365
nodeType: keyof typeof STORED_METADATA_WRITE_VERB,
@@ -364,9 +369,8 @@ function storedMetadataWriteRefusal(
364369
return {
365370
...refuseNode(
366371
`${nodeType}: refusing to ${STORED_METADATA_WRITE_VERB[nodeType]} '${objectName}': it holds stored `
367-
+ 'metadata, and a flow may not write it directly, so the write was not run. Change metadata through the '
368-
+ 'metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its '
369-
+ "provenance is recorded. Elevation (`runAs: 'system'`) does not change this.",
372+
+ 'metadata, and a flow may not write it directly, so the write was not run. '
373+
+ STORED_METADATA_BODY_PRESCRIPTION,
370374
),
371375
code: StandardErrorCode.enum.PERMISSION_DENIED,
372376
};

‎packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
5454
import { SqlDriver } from '@objectstack/driver-sql';
5555
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
5656
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
57+
import { STORED_METADATA_BODY_PRESCRIPTION } from '@objectstack/spec/kernel';
5758
import { AutomationServicePlugin } from '../plugin.js';
5859
import type { AutomationEngine, NodeExecutor } from '../engine.js';
5960

@@ -78,6 +79,16 @@ const BODY_FRAGMENT = '"label":"Pin body"';
7879
*/
7980
const STAND_IN_TARGET = 'pin_stand_in_target';
8081

82+
/**
83+
* The closing sentence of `text`, measured against the family's ONE
84+
* prescription, which both the save-time and the run-time refusal end on. Read
85+
* from the imported constant, never restated, so a later rewording of that one
86+
* sentence moves these pins with it.
87+
*/
88+
function closingPrescriptionOf(text: string): string {
89+
return text.slice(-STORED_METADATA_BODY_PRESCRIPTION.length);
90+
}
91+
8192
/** One write node in a flow definition aimed at a family table, and where it sits. */
8293
interface FamilyTarget {
8394
readonly path: string;
@@ -264,7 +275,10 @@ function harness(ql: ObjectQL, automation: AutomationEngine) {
264275
(thrown!.issues ?? []).map((i) => ({ code: i.code, path: i.path.join('.') })),
265276
`${def.name}: the save-time refusal's issues`,
266277
).toEqual(targets.map((t) => ({ code: 'custom', path: t.path })));
267-
for (const issue of thrown!.issues ?? []) expect(issue.message).toContain('the metadata protocol');
278+
for (const issue of thrown!.issues ?? []) {
279+
expect(closingPrescriptionOf(issue.message), `${def.name}: the save-time refusal ends on the family's prescription`)
280+
.toBe(STORED_METADATA_BODY_PRESCRIPTION);
281+
}
268282
expect(await automation.getFlow(def.name), `${def.name}: a refused flow was registered`).toBeNull();
269283
expect(await Promise.all(tables.map((object) => snapshot(object))), `${def.name}: the save-time refusal changed a table`)
270284
.toEqual(before);
@@ -342,7 +356,8 @@ async function expectRefused(h: Harness, object: FamilyTable, nodeType: WriteNod
342356
const where = `${nodeType} on ${object}, runAs '${runAs}'`;
343357
expect(run.res.success, `${where}: the run must fail`).toBe(false);
344358
expect(run.res.status, `${where}: the run's status`).toBe('failed');
345-
expect(String(run.res.error), `${where}: the refusal names the metadata protocol`).toContain('the metadata protocol');
359+
expect(closingPrescriptionOf(String(run.res.error)), `${where}: the run-time refusal ends on the family's prescription`)
360+
.toBe(STORED_METADATA_BODY_PRESCRIPTION);
346361
expect(run.downstreamRan, `${where}: the node downstream of the refusal ran`).toBe(false);
347362
expect(run.familyWrites, `${where}: the engine's write verb was called on the family table`).toBe(0);
348363
expect(await h.snapshot(object), `${where}: the family table changed`).toBe(before);

0 commit comments

Comments
 (0)