Skip to content

Commit 5ac2ba1

Browse files
fix(service-automation): flow write-node family refusal ends on the shared prescription sentence (#21624) (#21707)
Fixes #21624 Clause-②: no Part 3 of 3 on this card: the follow-up the seat's ACCEPT named and the spec lane's pointer handed back. Part 1, the run-time refusal, landed as PR #21649 (`f40bb3217f`). Part 2, the save-time `FlowSchema` refusal, landed as PR #21687 (`a2aadab1c6`). With this PR, the run-time and save-time refusals end on one sentence, and the card is complete. ## What changes - `packages/services/service-automation/src/builtin/crud-nodes.ts`, `storedMetadataWriteRefusal`: the message keeps its node-specific lead (the node type, what it would have done and the target table, and "so the write was not run"). It now ends on `STORED_METADATA_BODY_PRESCRIPTION`, imported from `@objectstack/spec/kernel` beside `isStoredMetadataBodyObject`, which the file already imported from that subpath. Its docblock gains one paragraph that names the shared sentence. - The only wording change an author sees is the elevation clause. It was "Elevation (`runAs: 'system'`) does not change this." and is now "Elevation (`runAs`, a system context) does not change this." The rest of the closing sentence was already byte-identical to the constant. - `write-nodes-stored-metadata-family-refusal.integration.test.ts`: the two message-text assertions (the save-time issue message and the run-time run error) used to check for a restated fragment, `toContain('the metadata protocol')`. Each now asserts that the message ENDS on the imported constant, through a small `closingPrescriptionOf` helper. Every refusal, no-write, code and identity assertion is unchanged, and none is deleted. - A `patch` changeset for `@objectstack/service-automation`. What is unchanged: the set of refused writes, the `PERMISSION_DENIED` code, the guard classification (a `fault` edge does not route it), and every non-family write. ## The dispatch's assumptions, as measured 1. **The constant's wording is true for every run-time caller.** The refusal runs before any identity is resolved, and the flow engine elevates in one way only: `runAs: 'system'` (`resolveRunDataContext` gives `isSystem: true`). "Elevation (`runAs`, a system context) does not change this" therefore holds for each run-time path. No sentence becomes false, and the constant was not touched. 2. **The `@objectstack/spec/kernel` subpath already reaches this package's build and tests.** `@objectstack/spec` is a declared dependency, and `crud-nodes.ts` already imported from `@objectstack/spec/kernel`. The vitest config has no source alias for `@objectstack/spec`, so tests reach it through `exports` (the spec package's built `kernel` entry). The source module and the pin read the same object. That pair is already recorded in `check-test-source-alias.mjs`'s `KNOWN_UNALIASED_TEST_IMPORTS` for this package, and `check:test-source-alias` exits 0. The built `dist/index.js` carries 2 hits for the constant and 0 for the old clause, and `check:dual-build-cjs-loads` exits 0. 3. **The pins import the constant.** No assertion was deleted (see above). 4. **Ablation:** see below. Predicted and observed agree. 5. **The runtime body boundary's private `PRESCRIPTION`** (`packages/runtime/src/stored-metadata-body-boundary.ts`, `domain:cli`): it is byte-identical to the shared constant (211 bytes each, compared programmatically). It is a copy, not an import. Not edited; see the acceptance notes. ## Verification (all at `1b9252e0f2` unless stated) Every heavy run went through `scripts/pm/os-verify-lock.sh`, and each verdict below is read from its `VERDICT command-exit` line. - `pnpm --filter @objectstack/service-automation test` (`vitest run`): **Test Files 170 passed (170), Tests 2098 passed (2098)**, exit 0. The same reading was taken at `d589cd4418`, before `origin/main` (`8843505d91`, objectql only) was merged in and the closure rebuilt. - The pin file alone, at `d589cd4418`: 17 passed (17). - `pnpm --filter @objectstack/service-automation typecheck`: exit 0, and `check:test-typecheck` OK (0 files in the ledger). `tsc --noEmit --listFiles -p tsconfig.json` lists the pin file (1 hit) and `crud-nodes.ts` (1 hit). - **Ablation, with the direction predicted before the run.** The mutation appends one word to the run-time closing sentence, inside the sentence. The anchor was `+ STORED_METADATA_BODY_PRESCRIPTION,` in `crud-nodes.ts`, and the replacement calls `.replace('change this.', 'change this ABLATIONMARKER.')` on it, applied with `scripts/ablation-replace.mjs`. - Prediction: 1 file red. 9 tests red, every case that goes through `expectRefused` (6 node x identity cases without the security plugin, 3 node cases with it), each failing first on the run-time "ends on the family's prescription" assertion. 8 green. The save-time assertion never red. Package total: 9 failed / 2089 passed. - On disk: the anchor went 1 to 0 and the replacement 0 to 1, and the blob changed from `86ed89180f` to `76faa10823`. The subject is reached through relative `src` imports (`../plugin.js`, then `./builtin/index.js`), so no `dist` rebuild or preflight applies. - Observed, full package suite: **Test Files 1 failed and 169 passed (170); Tests 9 failed and 2089 passed (2098)**. All 9 `AssertionError`s are the run-time prescription assertion, and 0 are the save-time one. - Restore, as reported by the tool: the blob after restore is `86ed89180f` and equals HEAD's, and `git diff HEAD` is empty. An own `trap` (`git checkout HEAD --` on the absolute path, then a hash comparison) re-confirmed it with 0 diff lines, and porcelain was empty. - Lint, as a proven narrowing (`pnpm lint` itself belongs to CI): - Population, read from eslint's own config: 2 of the 3 changed paths are linted. For the changeset, eslint answers "File ignored because no matching configuration was supplied". - Count, from `--format json --no-inline-config`: 3 results, with 0 errors and 0 warnings on the 2 TS files. - Invariance: `eslint.config.mjs` sets no `parserOptions.project` and no `projectService`, so the linting is not type-aware and this diff cannot move an untouched file's verdict. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths) derives 64 commands (30 pnpm, 34 node), the same list before and after the merge. - All 64 were run. 63 exited 0 on the first pass. - `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: unbuilt packages, nothing measured). After a full `turbo run build` (72/72) it exited 0, measuring 106 entries across 66 packages. - `--ran` reconciliation: **64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN**, exit 0. `check:nul-bytes` exited 0, and a control-byte scan of the 3 changed files found none. - Docs and skills: `content/docs/**` (outside `releases/`) and `skills/**` hold 0 copies of the old run-time sentence. The positive control was the same patterns on `crud-nodes.ts` before the edit (3 hits), and the pathspec control was `create_record` in `content/docs/automation/flows.mdx` (7 hits). Nothing to edit. ## Acceptance notes - **The runtime body boundary's `PRESCRIPTION` is a second copy of the sentence**, in `packages/runtime/src/stored-metadata-body-boundary.ts`. Today it is byte-identical to `STORED_METADATA_BODY_PRESCRIPTION`, so no author reads two wordings. But the constant's own docblock says to import it rather than restate it, and a later rewording would leave this copy behind. That file already imports `isStoredMetadataBodyObject` from `@objectstack/spec/kernel`, so the change is one import. It is another lane's file (`domain:cli`) and is not edited here. Carrier: none. - The spec's ADR-0087 semantic migration prescriptions for the hook and flow refusals restate the same elevation clause as frozen text. They are in the spec lane and match the shared wording. - The unreleased part 1 changeset describes the refusal in prose and does not quote the elevation clause, so it is not made false. It is not edited, since it is not this PR's changeset. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7fd2c34 commit 5ac2ba1

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)