Skip to content

Commit 5b843fb

Browse files
authored
fix(automation,spec): the cold-boot flow bind must survive the read path's own annotations (#4424)
* fix(automation,spec): the cold-boot flow bind must survive the read path's own annotations `getMetaItems({ type: 'flow' })` decorates every served item with `_diagnostics` (and `_draft` on a preview read). The cold-boot bind fed that served document straight into `engine.registerFlow` → `FlowSchema.parse`, and since #4001 closed the metadata schemas an unrecognized key THROWS instead of being dropped. So every flow failed to register on every boot: WARN [Automation] cold-boot flow bind: failed to register task_hours_pause_project: { "code": "unrecognized_keys", "keys": ["_diagnostics"], … } Not AI-specific — cloud's boot-smoke saw it on the sample package's `overdue_escalation` / `task_completion` / `quick_add_task` too. The stored metadata was always clean; we broke our own parse with our own annotation. Not fatal today only by luck: the record-change plugin binds record flows by a second path, so automations kept firing behind the WARN. A flow whose only binding path is this one would have gone silently dead. Fixed at the read seam (`readFlowDefsFromProtocol`), not by loosening `FlowSchema`: the payload is malformed because WE decorated it, so the producer's annotation is the producer's to remove — widening the schema would make our own read shape a second, permanent contract. The canonical decoration list moves from `metadata-protocol` (module-private) into `@objectstack/spec/kernel`, because the producer and the consumers sit in different layers and must not drift; `metadata-protocol` imports it and re-exports `stripReadDecorations` unchanged. The strip removes ONLY the read decorations — never the ADR-0010 envelope (`_lock`, `_packageId`, …), which `FLOW_KEYS` allowlists and a rebind must preserve. Regression coverage, all three verified to fail without the fix: • flow-cold-boot-bind.test.ts — a decorated payload binds, in both the bare and `{ item: … }` envelope shapes, with `_packageId` preserved. • protocol.read-decorations.test.ts — against the REAL `getMetaItems`: the raw served flow must still be REJECTED (the strip is load-bearing), the stripped one must parse, and every key the read ADDS must be either a known decoration or allowlisted by the closed schema. That last one is the drift guard: stamping a new `_foo` on the read path fails it by name with the fix to apply. Refs cloud#971, #4001, #4326. * chore: add changeset for the cold-boot flow bind fix * chore(spec): record the three added kernel exports in the API-surface snapshot METADATA_READ_DECORATIONS / MetadataReadDecoration / stripReadDecorations — additive only (0 breaking), moved in from metadata-protocol so the read-path producer and its cross-layer consumers share one definition. --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
1 parent c03108c commit 5b843fb

8 files changed

Lines changed: 332 additions & 43 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/metadata-protocol": patch
4+
"@objectstack/service-automation": patch
5+
---
6+
7+
fix(automation,spec): the cold-boot flow bind must survive the read path's own annotations (cloud#971)
8+
9+
`getMetaItems({ type: 'flow' })` decorates every served item with
10+
`_diagnostics` (and `_draft` on a preview read). The cold-boot bind fed that
11+
served document straight into `engine.registerFlow``FlowSchema.parse`, and
12+
since #4001 closed the metadata schemas an unrecognized key **throws** instead
13+
of being dropped — so every flow failed to register on every boot with
14+
`unrecognized_keys: ["_diagnostics"]`. Not fatal only by luck: the
15+
record-change plugin binds record flows a second way, so automations kept
16+
firing behind one WARN per flow. A flow whose only binding path is this one
17+
would have gone silently dead.
18+
19+
Fixed at the read seam (`readFlowDefsFromProtocol`), not by loosening
20+
`FlowSchema`: the payload is malformed because we decorated it, so the
21+
producer's annotation is the producer's to remove.
22+
23+
`@objectstack/spec` gains `METADATA_READ_DECORATIONS` / `stripReadDecorations`
24+
(`kernel/metadata-read-decorations`) — the list moves out of
25+
`metadata-protocol`, where it was module-private, so the producer and its
26+
cross-layer consumers share one definition. `metadata-protocol` re-exports
27+
`stripReadDecorations` unchanged; no public surface is removed.

packages/metadata-protocol/src/protocol.read-decorations.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,16 @@
1414
* shadows the stale one — which is exactly why this needs a pin: the invariant
1515
* being protected is "a GET → PUT round-trip persists a byte-identical body",
1616
* and only the stored bytes can show it.
17+
*
18+
* cloud#971 then showed the SECOND consumer of the same invariant, where it is
19+
* not cosmetic at all: since #4001 closed the metadata schemas, a served
20+
* document handed back to its own schema THROWS on our annotation. The last
21+
* describe block pins that — and, more importantly, pins the general rule, so a
22+
* future third decoration key fails here instead of in a production boot log.
1723
*/
1824
import { describe, expect, it } from 'vitest';
25+
import { FlowSchema } from '@objectstack/spec/automation';
26+
import { METADATA_READ_DECORATIONS } from '@objectstack/spec/kernel';
1927
import { ObjectStackProtocolImplementation, stripReadDecorations } from './index.js';
2028

2129
interface Row {
@@ -214,3 +222,95 @@ describe('saveMetaItem — the Studio round-trip persists a byte-identical body
214222
expect(after).toBe(before);
215223
});
216224
});
225+
226+
/** A minimal but complete record-change flow — what the automation service binds. */
227+
const flowBody = (name: string) => ({
228+
name,
229+
label: 'Pause project when hours are logged',
230+
type: 'record_change',
231+
status: 'active',
232+
nodes: [
233+
{
234+
id: 'start',
235+
type: 'start',
236+
label: 'Start',
237+
config: { objectName: 'task', triggerType: 'record-after-update' },
238+
},
239+
{ id: 'end', type: 'end', label: 'End' },
240+
],
241+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
242+
});
243+
244+
/**
245+
* cloud#971 — the read path's annotations must not break a strict re-parse.
246+
*
247+
* This is the same invariant as the round-trip block above, seen from the other
248+
* side. `saveMetaItem` already strips on the WRITE path; the cold-boot flow bind
249+
* (`service-automation`: `getMetaItems('flow')` → `registerFlow` →
250+
* `FlowSchema.parse`) re-parses instead of persisting, and since #4001 closed
251+
* `FlowSchema` that parse THREW `unrecognized_keys: ["_diagnostics"]` for every
252+
* flow on every boot — an entire binding path dead behind a WARN, masked only
253+
* because the record-change plugin binds record flows a second way.
254+
*
255+
* These run against the REAL `getMetaItems`, so they fail if the read path ever
256+
* grows a decoration that consumers don't know to remove.
257+
*/
258+
describe('a served document survives its own (closed) schema — cloud#971', () => {
259+
/** Serve `flowBody(name)` back through the real read path. */
260+
async function serveFlow(name: string): Promise<Record<string, unknown>> {
261+
const { engine } = makeStubEngine();
262+
const protocol = new ObjectStackProtocolImplementation(engine);
263+
await protocol.saveMetaItem({ type: 'flow', name, item: flowBody(name) });
264+
const res: any = await protocol.getMetaItems({ type: 'flow' });
265+
const items: any[] = Array.isArray(res) ? res : (res?.items ?? []);
266+
const served = items.find((i) => i?.name === name);
267+
expect(served, `getMetaItems('flow') served ${name}`).toBeDefined();
268+
return served as Record<string, unknown>;
269+
}
270+
271+
it('the raw served flow does NOT parse — the strip is load-bearing', async () => {
272+
const served = await serveFlow('task_hours_pause_project');
273+
expect(served._diagnostics).toBeDefined(); // precondition — the read decorates
274+
275+
const raw = FlowSchema.safeParse(served);
276+
expect(raw.success, 'a decorated flow must still be rejected by the closed schema').toBe(false);
277+
// Exactly the production symptom, so a reader of this test can match it
278+
// against the WARN in the issue.
279+
expect(raw.error!.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true);
280+
});
281+
282+
it('stripping the read decorations makes it parse — the cold-boot bind path', async () => {
283+
const served = await serveFlow('task_hours_pause_project');
284+
const parsed = FlowSchema.safeParse(stripReadDecorations(served));
285+
expect(
286+
parsed.success,
287+
`flow must bind after the strip; issues: ${JSON.stringify(parsed.error?.issues)}`,
288+
).toBe(true);
289+
});
290+
291+
it('every key the read ADDS is either a known decoration or allowed by the schema', async () => {
292+
// The drift guard. `stripReadDecorations` only removes what
293+
// METADATA_READ_DECORATIONS lists, so a NEW annotation stamped by the
294+
// read path would sail past it and start throwing in `registerFlow`
295+
// again. Diff the served document against the authored one and hold
296+
// every added key to one of the two escapes.
297+
const name = 'task_hours_pause_project';
298+
const served = await serveFlow(name);
299+
const authoredKeys = new Set(Object.keys(flowBody(name)));
300+
const added = Object.keys(served).filter((k) => !authoredKeys.has(k));
301+
302+
const unaccounted = added.filter((k) => {
303+
if ((METADATA_READ_DECORATIONS as readonly string[]).includes(k)) return false;
304+
// Not a decoration ⇒ it must be envelope state the closed schema
305+
// allowlists (the ADR-0010 `_lock`/`_packageId` family).
306+
return !FlowSchema.safeParse({ ...stripReadDecorations(served) as object, [k]: served[k] }).success;
307+
});
308+
309+
expect(
310+
unaccounted,
311+
'the read path stamped a key that is neither stripped (add it to '
312+
+ 'METADATA_READ_DECORATIONS in @objectstack/spec) nor accepted by the closed schema — '
313+
+ 'every strict re-parse of a served flow, including the cold-boot bind, now throws',
314+
).toEqual([]);
315+
});
316+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 14 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
type MetadataProvenance,
4141
} from '@objectstack/spec/kernel';
4242
import { validateObjectNamespacePrefix, deriveNamespaceFromPackageId } from '@objectstack/spec/kernel';
43+
import { stripReadDecorations } from '@objectstack/spec/kernel';
4344
import { z } from 'zod';
4445
import {
4546
computeMetadataDiagnostics,
@@ -364,48 +365,22 @@ function describeMalformedFilter(filter: unknown[]): string {
364365
}
365366

366367
/**
367-
* Keys the READ path stamps onto a served metadata document, which therefore
368-
* must never survive back into a persisted body (#4326).
368+
* The keys THIS file stamps onto every served document (`_diagnostics` via
369+
* `decorateMetadataItem`, `_draft` via the draft-preview overlay) — and the
370+
* strip that keeps them out of a persisted body (#4326) or a strict re-parse
371+
* (cloud#971).
369372
*
370-
* Both are recomputed on every read, so persisting them stores a stale copy of
371-
* something the reader already replaces:
372-
* - `_diagnostics` — the spec-validation verdict `decorateMetadataItem`
373-
* spreads onto every `getMetaItem`/`getMetaItems` result. A second producer
374-
* stamps the same key: view-container expansion records a name-collision
375-
* rename warning (`stampRenameWarning`, spec/ui/view.zod.ts). Both are
376-
* derived from the document, so neither belongs inside it;
377-
* - `_draft` — the preview badge added by draft reads. Draft-ness lives in
378-
* the row's `state` column and the `mode` parameter, never in the body.
373+
* The list itself lives in `@objectstack/spec` because this module PRODUCES the
374+
* decoration while consumers in other layers (`service-automation`'s cold-boot
375+
* flow bind, …) have to REMOVE it: one shared definition is what stops the two
376+
* sides from drifting. See `spec/kernel/metadata-read-decorations.ts` for the
377+
* full rationale and for why the ADR-0010 protection envelope (`_lock`,
378+
* `_packageId`, …) is deliberately not stripped despite the shared spelling.
379379
*
380-
* Deliberately NOT stripped, though they share the underscore spelling: the
381-
* ADR-0010 protection envelope (`_lock`, `_lockReason`, `_provenance`) and
382-
* `_packageId`. Those are envelope state the write path legitimately carries
383-
* and merges (see `mergeArtifactProtection`) — not read-time decoration.
380+
* Re-exported here so `@objectstack/metadata-protocol`'s public surface is
381+
* unchanged.
384382
*/
385-
const READ_ONLY_DECORATIONS = ['_diagnostics', '_draft'] as const;
386-
387-
/**
388-
* Remove {@link READ_ONLY_DECORATIONS} from an about-to-persist body.
389-
*
390-
* A **silent** strip, unlike the layered-envelope rejection in `saveMetaItem`:
391-
* that envelope is a wrong document the caller must fix, whereas these keys are
392-
* our own decoration riding along on a document that is otherwise exactly what
393-
* the author edited. Rejecting the standard GET → edit → PUT round-trip would
394-
* be hostile; stripping restores the invariant that a round-trip persists the
395-
* body byte-identical.
396-
*
397-
* Returns the SAME reference when there is nothing to strip, so the common path
398-
* allocates nothing (the discipline {@link graftNormalizedOperators} follows).
399-
* Non-object inputs pass through — the caller's own validation owns those.
400-
*/
401-
export function stripReadDecorations(item: unknown): unknown {
402-
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
403-
const dict = item as Record<string, unknown>;
404-
if (!READ_ONLY_DECORATIONS.some((k) => k in dict)) return item;
405-
const next = { ...dict };
406-
for (const k of READ_ONLY_DECORATIONS) delete next[k];
407-
return next;
408-
}
383+
export { stripReadDecorations };
409384

410385
/**
411386
* Guarantee a `view` body carries a top-level `name`.

packages/services/service-automation/src/flow-cold-boot-bind.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,22 @@ function fakeProtocolService(flows: unknown[]) {
7878
};
7979
}
8080

81+
/**
82+
* Decorate a flow the way the REAL read path does: `decorateMetadataItem`
83+
* spreads `_diagnostics` onto every served item, a preview read badges `_draft`,
84+
* and an overlay row carries its `_packageId`. cloud#971 — the first two are
85+
* read-time annotations the closed `FlowSchema` (#4001) rejects; the third is
86+
* ADR-0010 envelope state `FLOW_KEYS` allowlists and the bind must PRESERVE.
87+
*/
88+
function asServedByProtocol<T extends object>(flow: T) {
89+
return {
90+
...flow,
91+
_packageId: 'app.pm7k',
92+
_diagnostics: { valid: true, warnings: [] },
93+
_draft: true,
94+
};
95+
}
96+
8197
/**
8298
* Boot with a protocol service that HAS the flow but NO objectql registry, so
8399
* the boot pull is empty — exactly the inline-app-flow cold-boot scenario. The
@@ -130,3 +146,72 @@ describe('record-triggered flow binds on cold boot (kernel:ready sync)', () => {
130146
await kernel.shutdown();
131147
});
132148
});
149+
150+
/**
151+
* cloud#971 — the bind must survive the read path's OWN annotations.
152+
*
153+
* The fake above served naked flow bodies; the real `getMetaItems` decorates
154+
* every item (`_diagnostics`, plus `_draft` on a preview read). Once #4001
155+
* closed `FlowSchema` — unrecognized keys throw instead of being dropped —
156+
* `registerFlow` rejected EVERY flow on EVERY cold boot with
157+
* `unrecognized_keys: ["_diagnostics"]`. Nothing looked broken because the
158+
* record-change plugin binds record flows by a second path; a flow whose only
159+
* binding path is this one would simply have stopped firing, silently.
160+
*
161+
* So the naked-payload tests above could not have caught it. These serve what
162+
* the protocol actually serves.
163+
*/
164+
describe('cold-boot bind survives the read path annotations (cloud#971)', () => {
165+
it('binds a flow served WITH _diagnostics/_draft decorations', async () => {
166+
const rec = recordingRecordChangeTrigger();
167+
const kernel = await bootKernel(
168+
[asServedByProtocol(recordTriggeredFlow('task_hours_pause_project', 'task'))],
169+
rec,
170+
);
171+
await flush();
172+
173+
expect(
174+
rec.has('task_hours_pause_project'),
175+
'a decorated flow must still bind — pre-fix this threw unrecognized_keys and only WARNed',
176+
).toBe(true);
177+
178+
const engine = kernel.getService<AutomationEngine>('automation');
179+
expect(await engine.getFlow('task_hours_pause_project')).not.toBeNull();
180+
181+
await kernel.shutdown();
182+
});
183+
184+
it('keeps the ADR-0010 protection envelope — the strip is not a blanket "_" purge', async () => {
185+
// `_packageId` shares the underscore spelling but is envelope state
186+
// `FLOW_KEYS` allowlists. Dropping it would erase a packaged flow's
187+
// provenance on every rebind, so the strip must be exactly the read
188+
// decorations and nothing more.
189+
const rec = recordingRecordChangeTrigger();
190+
const kernel = await bootKernel(
191+
[asServedByProtocol(recordTriggeredFlow('task_hours_pause_project', 'task'))],
192+
rec,
193+
);
194+
await flush();
195+
196+
const engine = kernel.getService<AutomationEngine>('automation');
197+
const parsed = await engine.getFlow('task_hours_pause_project');
198+
expect((parsed as unknown as { _packageId?: string })?._packageId).toBe('app.pm7k');
199+
expect(parsed).not.toHaveProperty('_diagnostics');
200+
expect(parsed).not.toHaveProperty('_draft');
201+
202+
await kernel.shutdown();
203+
});
204+
205+
it('binds a decorated flow served in the `{ item: … }` wrapper shape', async () => {
206+
// The other envelope `getMetaItems` can hand back — the strip has to
207+
// happen AFTER the unwrap, not before it.
208+
const rec = recordingRecordChangeTrigger();
209+
const kernel = await bootKernel(
210+
[{ item: asServedByProtocol(recordTriggeredFlow('task_hours_pause_project', 'task')) }],
211+
rec,
212+
);
213+
await flush();
214+
expect(rec.has('task_hours_pause_project')).toBe(true);
215+
await kernel.shutdown();
216+
});
217+
});

packages/services/service-automation/src/plugin.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
ConnectorProviderContext,
1212
} from '@objectstack/spec/integration';
1313
import { isConnectorUpstreamUnavailable } from '@objectstack/spec/integration';
14+
import { stripReadDecorations } from '@objectstack/spec/kernel';
1415
import { AutomationEngine } from './engine.js';
1516
import type { RunSummaryLogLevel } from './engine.js';
1617
import { installBuiltinNodes, rearmSuspendedWaitTimers } from './builtin/index.js';
@@ -1177,6 +1178,24 @@ export class AutomationServicePlugin implements Plugin {
11771178
* source this re-sync read before this fix — it is actually populated in a
11781179
* real running server (`metadata.list('flow')` returns 0 there, so the old
11791180
* re-sync bound nothing).
1181+
*
1182+
* Every doc is handed back with the READ path's own annotations removed
1183+
* (cloud#971). A served document is not a valid `FlowSchema` input: the
1184+
* protocol decorates each item with `_diagnostics` (and `_draft` on a
1185+
* preview read), and since #4001 `FlowSchema` REJECTS unrecognized keys
1186+
* instead of dropping them — so `registerFlow` threw
1187+
* `unrecognized_keys: ["_diagnostics"]` for EVERY flow on every cold boot.
1188+
* The bind was entirely dead; only the record-change plugin's separate
1189+
* binding path kept record automations firing, and a flow whose only
1190+
* binding path is this one would have silently stopped triggering.
1191+
*
1192+
* Stripped here — at the read seam — rather than leniently in
1193+
* `registerFlow`: the payload is malformed because WE decorated it, so the
1194+
* producer's annotation is the producer's to remove. Widening the schema
1195+
* would make our own read shape a second, permanent contract. Note the
1196+
* strip removes only the read decorations, never the ADR-0010 protection
1197+
* envelope (`_lock`, `_packageId`, …) — `FLOW_KEYS` allowlists those, and
1198+
* dropping them would strip a packaged flow's provenance on every rebind.
11801199
*/
11811200
private async readFlowDefsFromProtocol(
11821201
ctx: PluginContext,
@@ -1202,11 +1221,12 @@ export class AutomationServicePlugin implements Plugin {
12021221
// getMetaItems hands back a bare array or an `{ items: [...] }` envelope,
12031222
// and each entry is either the flow doc or an `{ item: <flow> }` wrapper.
12041223
const list = Array.isArray(raw) ? raw : (((raw as { items?: unknown[] })?.items) ?? []);
1205-
return list.map((entry) =>
1206-
(entry && typeof entry === 'object' && 'item' in entry
1224+
return list.map((entry) => {
1225+
const doc = entry && typeof entry === 'object' && 'item' in entry
12071226
? (entry as { item: unknown }).item
1208-
: entry) as { name?: string },
1209-
);
1227+
: entry;
1228+
return stripReadDecorations(doc) as { name?: string };
1229+
});
12101230
}
12111231

12121232
/**

packages/spec/api-surface.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,6 +1541,7 @@
15411541
"ListPackagesRequestSchema (const)",
15421542
"ListPackagesResponse (type)",
15431543
"ListPackagesResponseSchema (const)",
1544+
"METADATA_READ_DECORATIONS (const)",
15441545
"ManifestPermissions (type)",
15451546
"ManifestPermissionsSchema (const)",
15461547
"ManifestSchema (const)",
@@ -1603,6 +1604,7 @@
16031604
"MetadataQueryResult (type)",
16041605
"MetadataQueryResultSchema (const)",
16051606
"MetadataQuerySchema (const)",
1607+
"MetadataReadDecoration (type)",
16061608
"MetadataSaveOptions (type)",
16071609
"MetadataSaveOptionsSchema (const)",
16081610
"MetadataSaveResult (type)",
@@ -1870,6 +1872,7 @@
18701872
"registerMetadataTypeActions (function)",
18711873
"registerMetadataTypeSchema (function)",
18721874
"resolveLockState (function)",
1875+
"stripReadDecorations (function)",
18731876
"validateObjectNamespacePrefix (function)"
18741877
],
18751878
"./ai": [

packages/spec/src/kernel/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export * from './platform-capabilities';
2727
export * from './metadata-loader.zod';
2828
export * from './metadata-plugin.zod';
2929
export * from './metadata-protection.zod';
30+
// The read path's OWN annotations (`_diagnostics`, `_draft`) — the underscore
31+
// keys that, unlike the protection envelope above, must never survive back into
32+
// a persisted body or a strict re-parse (#4326, cloud#971).
33+
export * from './metadata-read-decorations';
3034
export * from './metadata-type-schemas';
3135
// Pre-parse unknown-key walker over EVERY metadata collection (#3786). Lives
3236
// here, not in data/, because covering every type means importing every schema.

0 commit comments

Comments
 (0)