Skip to content

Commit 27a1a45

Browse files
committed
fix(metadata-core): the artifact door refuses flow-decision-mode-inclusive-explicit by id (#15429 patch round)
The entry is a DEFAULT FLIP: an omitted `mode` IS the exclusive gateway by the contract on `DecisionConfigSchema`, so writing `mode: 'inclusive'` is a reinterpretation that is sound only where the source's age is a fact — `os migrate meta --from 17`. The artifact-ingestion door's trigger is the declared `engines.protocol` floor, and `^17.0.0` is what `create-objectstack` stamps, so an app scaffolded today against the exclusive contract lands inside the window and would be handed an inclusive gateway it never asked for. The id joins `DEFAULT_FLIPS_NOT_REPLAYED_HERE` beside the `app-hidden-to-unpublished` precedent, with its reason; the door pin has four legs (subject, strict parse, negative, firing control through the primitive). The engine seam's `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` docblock and the conversion entry's docblock now cite the door precisely (「must」 became 「does」). Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e92edee commit 27a1a45

4 files changed

Lines changed: 141 additions & 12 deletions

File tree

‎packages/metadata-core/src/artifact-forward-conversion.test.ts‎

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*/
1818

1919
import { describe, it, expect } from 'vitest';
20-
import { ObjectStackDefinitionSchema, applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec';
20+
import { ObjectStackDefinitionSchema, applyConversions, applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec';
2121
import {
2222
applyArtifactForwardConversions,
2323
parseRangeFloor,
@@ -401,6 +401,108 @@ describe('the artifact door never turns an authored `hidden: true` into an unpub
401401
});
402402
});
403403

404+
/**
405+
* #15429 — the second member of the DEFAULT-FLIP class this door refuses.
406+
*
407+
* `flow-decision-mode-inclusive-explicit` writes `mode: 'inclusive'` onto an
408+
* edge-branched `decision` with two or more conditioned out-edges, so a flow
409+
* written while every true branch ran keeps that behaviour now that the
410+
* traversal is exclusive. An omitted `mode` IS the exclusive gateway by the
411+
* contract on `DecisionConfigSchema`, so the rewrite reinterprets a legal
412+
* shape — sound only where the source's age is a fact (`os migrate meta
413+
* --from 17`, the operator's assertion). At this door the trigger is the
414+
* artifact's declared `engines.protocol` floor, and `^17.0.0` is what
415+
* `create-objectstack` stamps: an app scaffolded today, authored against the
416+
* exclusive contract, lands inside the window. Replaying the entry here would
417+
* hand it an inclusive gateway it never asked for — the #17885 shape, on a
418+
* key whose omission is the ruled default.
419+
*
420+
* Same four legs as the block above: SUBJECT (window open, no `mode`
421+
* written) · the strict parse the door feeds · NEGATIVE (window shut) ·
422+
* FIRING CONTROL (the entry WOULD rewrite this very fixture with the window
423+
* open and no refusal, so the subject leg cannot pass vacuously).
424+
*/
425+
describe('the artifact door never writes `mode: inclusive` onto an authored exclusive decision (#15429)', () => {
426+
/** One edge-branched decision with two conditioned out-edges and no `mode` — the shape the entry rewrites. */
427+
const twoBranchDecisionDefinition = (protocolRange: string) => ({
428+
manifest: {
429+
id: 'app.example.leads', name: 'leads', version: '1.0.0', type: 'app',
430+
engines: { protocol: protocolRange },
431+
},
432+
flows: [{
433+
name: 'lead_verdict',
434+
label: 'Lead verdict',
435+
type: 'autolaunched',
436+
nodes: [
437+
{ id: 'start', type: 'start', label: 'Start' },
438+
{ id: 'verdict', type: 'decision', label: 'Verdict?' },
439+
{ id: 'refuse', type: 'end', label: 'Refuse' },
440+
{ id: 'convert', type: 'end', label: 'Convert' },
441+
],
442+
edges: [
443+
{ id: 'e1', source: 'start', target: 'verdict' },
444+
{ id: 'e2', source: 'verdict', target: 'refuse', condition: "lead.status != 'suspected'", label: 'Refuse' },
445+
{ id: 'e3', source: 'verdict', target: 'convert', condition: "lead.status == 'confirmed'", label: 'Convert' },
446+
],
447+
}],
448+
});
449+
const ID = 'flow-decision-mode-inclusive-explicit';
450+
const verdictNodeOf = (definition: unknown) =>
451+
(definition as { flows: { nodes: { id: string; config?: Record<string, unknown> }[] }[] })
452+
.flows[0]!.nodes.find((n) => n.id === 'verdict')!;
453+
454+
it('leaves the decision without `mode` on an artifact the retired window IS open for', () => {
455+
const def = twoBranchDecisionDefinition('^17.0.0');
456+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });
457+
458+
// ⭐ ANTI-VACUITY: the window really is open on this input.
459+
expect(result.verdict).toBe('converted-forward');
460+
expect(result.authoredFloor).toBe('17.0.0');
461+
462+
const verdict = verdictNodeOf(result.definition);
463+
expect(verdict.config, 'the authored exclusive gateway is untouched').toBeUndefined();
464+
expect(result.notices.map((n) => n.conversionId)).not.toContain(ID);
465+
// Copy-on-write: nothing was recognized, so the same reference comes back.
466+
expect(result.definition).toBe(def);
467+
});
468+
469+
it('registers the decision without `mode` — asserted AFTER the strict parse the door feeds', () => {
470+
const def = twoBranchDecisionDefinition('^17.0.0');
471+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });
472+
expect(result.verdict).toBe('converted-forward');
473+
474+
const parsed = ObjectStackDefinitionSchema.parse(result.definition);
475+
const registered = verdictNodeOf(parsed);
476+
expect(Object.keys(registered.config ?? {}), 'what registration receives').not.toContain('mode');
477+
});
478+
479+
it('floor ^99.0.0 — the window is shut and nothing is replayed at all', () => {
480+
const def = twoBranchDecisionDefinition('^99.0.0');
481+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });
482+
expect(result.verdict).toBe('authored-current');
483+
expect(result.notices).toEqual([]);
484+
expect(verdictNodeOf(result.definition).config).toBeUndefined();
485+
});
486+
487+
/**
488+
* ⭐ FIRING CONTROL — the entry WOULD rewrite this exact fixture: the same
489+
* bytes through the primitive with the retired window open and no refusal
490+
* come back inclusive, with the entry's own notice. A door that had merely
491+
* stopped recognizing the shape, or a fixture the entry never matched, would
492+
* go green above and prove nothing; this leg is what makes the subject a
493+
* reading of the refusal.
494+
*/
495+
it('FIRING CONTROL — without the refusal, the same fixture IS rewritten to `mode: inclusive`', () => {
496+
const notices: ConversionNotice[] = [];
497+
const rewritten = applyConversions(twoBranchDecisionDefinition('^17.0.0'), {
498+
includeRetired: true,
499+
onNotice: (n) => notices.push(n),
500+
});
501+
expect(verdictNodeOf(rewritten).config).toEqual({ mode: 'inclusive' });
502+
expect(notices.map((n) => n.conversionId)).toContain(ID);
503+
});
504+
});
505+
404506
describe('parseRangeFloor — the range spellings artifacts actually carry', () => {
405507
it.each([
406508
['^17.1.0', [17, 1, 0]],

‎packages/metadata-core/src/artifact-forward-conversion.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,8 +287,30 @@ export function resolveInstalledSpecVersion(): string | null {
287287
* materialization path, and `os migrate meta --from <=16`, where the
288288
* operator asserts the source's age) — this door is neither, so it opts out
289289
* rather than the entry ceasing to fire.
290+
* - `flow-decision-mode-inclusive-explicit` (#15429, maintainer ruling
291+
* 「跟主流对齐」): writes `mode: 'inclusive'` onto an edge-branched `decision`
292+
* (no `conditions` list) that has two or more conditioned out-edges, so a
293+
* flow written while every true branch ran keeps that behaviour now that the
294+
* traversal is exclusive (first true edge in declaration order). Both shapes
295+
* are legal and mean different things — an omitted `mode` IS the exclusive
296+
* gateway, by the contract on `DecisionConfigSchema` — so the rewrite is a
297+
* reinterpretation, sound only where "this source predates the flip" is a
298+
* fact. Here it is a guess: the trigger is the artifact's declared
299+
* `engines.protocol` floor, and `^17.0.0` is the range `create-objectstack`
300+
* stamps, so an app scaffolded today, whose author wrote two branches
301+
* against the contract that says an omitted `mode` is exclusive, lands
302+
* inside the window and would be handed an inclusive gateway it never
303+
* asked for — the ruled default made unobservable for every artifact-deployed
304+
* app. The entry is sound at exactly one seam: `os migrate meta --from 17`,
305+
* where the operator asserts the source's age and reviews the diff. The
306+
* automation engine's flow rehydration seam refuses it by id for the same
307+
* reason (`CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` in
308+
* `packages/services/service-automation/src/engine.ts`); this door does too.
290309
*/
291-
const DEFAULT_FLIPS_NOT_REPLAYED_HERE: readonly string[] = ['app-hidden-to-unpublished'];
310+
const DEFAULT_FLIPS_NOT_REPLAYED_HERE: readonly string[] = [
311+
'app-hidden-to-unpublished',
312+
'flow-decision-mode-inclusive-explicit',
313+
];
292314

293315
/**
294316
* Apply the versioned forward conversion to one compiled-artifact definition.

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2126,11 +2126,12 @@ export const IN_PROCESS_DISPATCH_CLAIM_TTL_MS = 48 * 60 * 60 * 1000;
21262126

21272127
/**
21282128
* ADR-0087 conversions the flow rehydration seam refuses to replay, by id —
2129-
* the DEFAULT-FLIP class, on the artifact door's precedent
2130-
* (`DEFAULT_FLIPS_NOT_REPLAYED_HERE` in `@objectstack/metadata-core`). The
2131-
* registry stays the single authority on what converts; this is only this
2132-
* seam saying which entries its own evidence cannot carry, and each id owes
2133-
* its reason beside it.
2129+
* the DEFAULT-FLIP class, the same per-door refusal the artifact-ingestion
2130+
* door makes (`DEFAULT_FLIPS_NOT_REPLAYED_HERE` in
2131+
* `packages/metadata-core/src/artifact-forward-conversion.ts`, which lists this
2132+
* seam's one id beside its own precedent). The registry stays the single
2133+
* authority on what converts; this is only this seam saying which entries its
2134+
* own evidence cannot carry, and each id owes its reason beside it.
21342135
*
21352136
* - `flow-decision-mode-inclusive-explicit` (#15429) writes `mode: 'inclusive'`
21362137
* onto an edge-branched `decision` with two or more conditioned out-edges,
@@ -2145,7 +2146,9 @@ export const IN_PROCESS_DISPATCH_CLAIM_TTL_MS = 48 * 60 * 60 * 1000;
21452146
* new exclusive decision into an inclusive one at registration and persist
21462147
* that at save, and the ruled default would be unobservable. The entry
21472148
* replays where the age IS asserted: `os migrate meta --from 17`, by the
2148-
* operator, over authored sources — never at a load seam.
2149+
* operator, over authored sources — never at a load seam. The artifact
2150+
* door refuses it for the same reason (its declared `^17.0.0` floor is a
2151+
* dependency range, not an age), in the module named above.
21492152
*/
21502153
const CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION: readonly string[] = [
21512154
'flow-decision-mode-inclusive-explicit',

‎packages/spec/src/conversions/registry.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11389,10 +11389,12 @@ const reportJoinedChartRemoved: MetadataConversion = {
1138911389
* data-at-rest seam that opens the retired window has to refuse this id by
1139011390
* name, on the artifact door's precedent for `app-hidden-to-unpublished`
1139111391
* (#17885, `DEFAULT_FLIPS_NOT_REPLAYED_HERE` in `@objectstack/metadata-core`):
11392-
* the automation engine's flow rehydration seam does (`registerFlow` serves
11393-
* code-shipped flows, REST bodies and Studio saves alike, none of them dated),
11394-
* and the artifact-ingestion door must (a scaffolded `^17.0.0` floor is a
11395-
* dependency range, not an age). ⛔ Not replayed over stored `sys_metadata`
11392+
* the automation engine's flow rehydration seam does
11393+
* (`CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` in `service-automation/src/engine.ts`:
11394+
* `registerFlow` serves code-shipped flows, REST bodies and Studio saves alike,
11395+
* none of them dated), and the artifact-ingestion door does
11396+
* (`DEFAULT_FLIPS_NOT_REPLAYED_HERE` in `metadata-core/src/artifact-forward-conversion.ts`:
11397+
* a scaffolded `^17.0.0` floor is a dependency range, not an age). ⛔ Not replayed over stored `sys_metadata`
1139611398
* flows by `os migrate meta --stored` either — that pass canonicalizes through
1139711399
* the same engine seam, and an operator-asserted rewrite of stored rows is a
1139811400
* separate plumbing, named by the D3 entry as the judgment still owed.

0 commit comments

Comments
 (0)