Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/21654-flow-write-node-stored-metadata-target-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@objectstack/spec': minor
---

A flow `create_record`, `update_record` or `delete_record` node whose `objectName` is `sys_metadata` or `sys_metadata_history` is refused at parse, with the runtime's prescription: change metadata through the metadata API.

Clause-②: yes (narrowing)

<!-- adr-0087: registered flow-write-node-stored-metadata-target-refused -->

**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.

**Why.** App-authored work may not write the two stored-metadata tables: the metadata protocol is their only writer, where a change is validated and its provenance is recorded, and a flow is app-authored automation. The runtime already enforces that at the node: the three write nodes refuse such a target before they resolve a filter, compute a field or call the data engine, under every run identity. But `FlowSchema` still accepted the flow, so `objectstack validate` passed it, the metadata save door answered 200 for it and `registerFlow` registered it, and the author learned otherwise only at its first run.

**What is refused.** A `create_record`, `update_record` or `delete_record` node, at any depth including an ADR-0031 region body, whose `config.objectName` is a string naming `sys_metadata` or `sys_metadata_history`. The issue's `code` is `custom`, at `nodes.N.config.objectName`, and its message names the node type and the table and ends with the runtime's prescription. The judge is `flowNodeConfigRefusals`, the one `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share, and its membership test is the kernel's own `isStoredMetadataBodyObject`, the predicate the runtime judges by. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.objectName`), `os validate`, an artifact's parse, `registerFlow` and the metadata save door (`422 INVALID_METADATA`). The refusal joins the closed flow slot refusal set as `write-node-stored-metadata-target`, with `params: { nodeType, objectName }`.

**What stays accepted, byte for byte.** A `get_record` node on those tables (a read is not a write; the runtime judges its reach at the run), a write node whose `objectName` is dynamic (a `{token}` template or an expression envelope: the parse cannot read it as a name, and the runtime judges the name it hands the data engine), and every write node on any other object.

**One prescription sentence.** `@objectstack/spec/kernel` now exports `STORED_METADATA_BODY_PRESCRIPTION`, the sentence the hook refusal and this flow refusal both end on. It was the hook refusal's private constant, moved unchanged.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| a `create_record` / `update_record` / `delete_record` node with `objectName: 'sys_metadata'` or `objectName: 'sys_metadata_history'` | change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`) instead, and delete the node |
| a write node on any other object, a `get_record` node, or a dynamic `objectName` | unchanged |

**The one-line fix: delete the node, or point its `objectName` at the object the flow really means to write, and make the metadata change through the metadata API.** The runtime never ran such a write, so removing it changes nothing a flow does.

**Who is affected, measured.** No authored flow writes either table in this repository's `packages/**`, `examples/**`, `skills/**`, `content/docs/**` or `docs/**` at `417443eb27` (229 write-node declarations); the only hits are the runtime's own tests of its node refusal. Deployed metadata was not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register.

### The kit

- **The refusal.** A third arm of `flowNodeConfigRefusals` (`automation/flow-node-config-refusals.ts`), beside the executor-contract arm and the decision arm.
- **The ledger.** The D3 semantic entry `flow-write-node-stored-metadata-target-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: a refused node carries no intent a rewrite could keep.
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@
* a flow variable leaves the family table unchanged, and the three nodes write
* an ordinary object exactly as before.
*
* [#21654] The save-time half. `FlowSchema` now refuses a write node whose
* STATIC `objectName` names a family table, and `registerFlow` parses first,
* so a flow carrying one is refused before it can run. Every static case here
* therefore asserts that refusal first (`registerFlow` throws, the issue sits at
* the node's `config.objectName`, the table is unchanged), and only then reaches
* the run-time guard, with a definition the parse never judged: see
* {@link registerForRun}. A dynamic target (`{record.target}`) is not judged at
* save and registers as before.
*
* Composition: `ObjectKernel`, `ObjectQLPlugin`, `driver-sql` on
* better-sqlite3 `:memory:` and the real `AutomationServicePlugin`, the stack
* the family read pins boot; the secured composition adds the real
Expand Down Expand Up @@ -60,6 +69,49 @@ type RunAs = 'system' | 'user';
const STORED_BODY = JSON.stringify({ name: 'pin_body', label: 'Pin body' });
const BODY_FRAGMENT = '"label":"Pin body"';

/**
* [#21654] The target a static family case is registered under, so that the
* parse lets it through; {@link registerForRun} then puts the family table back
* on the registered definition. No object of this name exists: a definition
* whose retarget did not land fails its run with a not-found error, never with
* the family refusal its case asserts.
*/
const STAND_IN_TARGET = 'pin_stand_in_target';

/** One write node in a flow definition aimed at a family table, and where it sits. */
interface FamilyTarget {
readonly path: string;
readonly object: string;
}

/**
* Every write node in `def`, at any depth (a `try_catch` region's nodes
* included), whose `config.objectName` is `match` — or, with no `match`, is a
* family table by name. Paths in the parse's dotted spelling
* (`nodes.1.config.objectName`).
*/
function writeTargetsIn(def: unknown, match?: string): Array<FamilyTarget & { readonly node: Record<string, unknown> }> {
const found: Array<FamilyTarget & { readonly node: Record<string, unknown> }> = [];
const visit = (value: unknown, path: string[]): void => {
if (Array.isArray(value)) {
value.forEach((item, i) => visit(item, [...path, String(i)]));
return;
}
if (value === null || typeof value !== 'object') return;
const rec = value as Record<string, unknown>;
const config = rec.config as Record<string, unknown> | undefined;
if ((WRITE_NODES as readonly unknown[]).includes(rec.type) && config && typeof config.objectName === 'string') {
const object = config.objectName;
if (match === undefined ? (FAMILY as readonly string[]).includes(object) : object === match) {
found.push({ path: [...path, 'config', 'objectName'].join('.'), object, node: config });
}
}
for (const [key, child] of Object.entries(rec)) visit(child, [...path, key]);
};
visit(def, []);
return found;
}

/** An ordinary object: the non-family control. */
const PLAIN_OBJECT = {
name: 'pin_write_plain',
Expand Down Expand Up @@ -169,9 +221,71 @@ function harness(ql: ObjectQL, automation: AutomationEngine) {
return { objectName: object, filter: { id } };
}

/**
* [#21654] Register `def` so that it can RUN. A definition with no static
* family target registers as it always did. One that carries such a target is
* refused by the parse at save, now that `FlowSchema` judges it, so this first
* asserts that refusal — `registerFlow` throws, the issue is a `custom` one at
* each such node's `config.objectName` carrying the metadata-protocol
* prescription, nothing is registered under the name, and the target table is
* unchanged — and only then reaches the run-time guard with a definition the
* parse never judged: the same definition registered with
* {@link STAND_IN_TARGET} in place of each family table, after which the
* family table is put back on the definition the engine holds.
*
* The engine behaviour this leans on, none of which the save-time refusal
* changes: `registerFlow` stores the parsed definition it returns, by
* reference (`this.flows.set(name, parsed)`, then `return parsed`), and
* `execute` runs `this.flows.get(name)` as stored, never re-parsing it. Both
* are read back here rather than assumed: `getFlow(name)` must answer the
* family table at every retargeted path before the run. Were either to stop
* holding — a copy, a freeze, a re-parse — the retarget would fail to land,
* that read-back would go red, and the run would refuse nothing for the family
* reason; a frozen definition throws on the write itself.
*/
async function registerForRun(def: { name: string }): Promise<void> {
const targets = writeTargetsIn(def);
if (targets.length === 0) {
automation.registerFlow(def.name, def as any);
return;
}

// Save time: refused, located at each family target, nothing registered, the table unchanged.
const tables = [...new Set(targets.map((t) => t.object))];
const before = await Promise.all(tables.map((object) => snapshot(object)));
let thrown: { issues?: Array<{ code: string; path: PropertyKey[]; message: string }> } | undefined;
try {
automation.registerFlow(def.name, def as any);
} catch (err) {
thrown = err as typeof thrown;
}
expect(thrown, `${def.name}: registerFlow must refuse a static family target at save`).toBeDefined();
expect(
(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');
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);

// Run time: a definition the parse never judged — registered aimed at the stand-in, then retargeted.
const standIn = JSON.parse(JSON.stringify(def)) as { name: string };
for (const target of writeTargetsIn(standIn)) target.node.objectName = STAND_IN_TARGET;
const registered = automation.registerFlow(def.name, standIn as any);
const placeholders = writeTargetsIn(registered, STAND_IN_TARGET);
expect(placeholders.map((p) => p.path), `${def.name}: the stand-in sits where the family targets did`)
.toEqual(targets.map((t) => t.path));
placeholders.forEach((placeholder, i) => {
placeholder.node.objectName = targets[i]!.object;
});
expect(writeTargetsIn(await automation.getFlow(def.name)), `${def.name}: the engine holds the retargeted definition`)
.toEqual(targets.map((t) => expect.objectContaining({ path: t.path, object: t.object })));
}

/** Run `def` with the engine's write verbs watched; count the calls aimed at the family. */
async function runWatched(def: { name: string }, trigger: Record<string, unknown>) {
automation.registerFlow(def.name, def as any);
await registerForRun(def);
const insert = vi.spyOn(ql, 'insert');
const update = vi.spyOn(ql, 'update');
const remove = vi.spyOn(ql, 'delete');
Expand All @@ -193,7 +307,7 @@ function harness(ql: ObjectQL, automation: AutomationEngine) {
async function codeAsAFlowReadsIt(runAs: RunAs, node: Record<string, unknown>, trigger: Record<string, unknown>) {
const name = `pin_code_${seq++}`;
captured.length = 0;
automation.registerFlow(name, {
await registerForRun({
name, label: name, type: 'autolaunched', runAs,
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
Expand All @@ -208,7 +322,7 @@ function harness(ql: ObjectQL, automation: AutomationEngine) {
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'guarded' }, { id: 'e2', source: 'guarded', target: 'end' }],
} as any);
} as { name: string });
await automation.execute(name, { ...trigger } as any);
expect(captured, 'the catch region must have run once').toHaveLength(1);
return captured[0]!;
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/kernel.json
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@
"SEMVER_2_0_0_VERSION_PATTERN (const)",
"STORED_METADATA_BODY_COLUMN (const)",
"STORED_METADATA_BODY_OBJECTS (const)",
"STORED_METADATA_BODY_PRESCRIPTION (const)",
"STORED_METADATA_TYPE_COLUMN (const)",
"SandboxConfig (type)",
"SandboxConfigParsed (type)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/kernel.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@
"SEMVER_2_0_0_VERSION_PATTERN": "src/kernel/version-grammar.ts#SEMVER_2_0_0_VERSION_PATTERN (const)",
"STORED_METADATA_BODY_COLUMN": "src/kernel/metadata-type-redaction.ts#STORED_METADATA_BODY_COLUMN (const)",
"STORED_METADATA_BODY_OBJECTS": "src/kernel/stored-metadata-body-objects.ts#STORED_METADATA_BODY_OBJECTS (const)",
"STORED_METADATA_BODY_PRESCRIPTION": "src/kernel/stored-metadata-body-objects.ts#STORED_METADATA_BODY_PRESCRIPTION (const)",
"STORED_METADATA_TYPE_COLUMN": "src/kernel/metadata-type-redaction.ts#STORED_METADATA_TYPE_COLUMN (const)",
"SandboxConfig": "src/kernel/plugin-security-advanced.zod.ts#SandboxConfig (type)",
"SandboxConfigParsed": "src/kernel/plugin-security-advanced.zod.ts#SandboxConfigParsed (type)",
Expand Down
Loading
Loading