Skip to content

Commit 0d7b1f3

Browse files
os-litantclaude
andauthored
fix(service-automation): one renderer for the contested-flow phrase (#12563) (#12607)
One event — a flow name claimed by more than one definition — was described to an operator in three places, each with its own private `const describe` beside the log call. Nothing held them equal, and two axes had already drifted: `flow-precedence.ts` rendered `package "crm"` while the other two rendered `package 'crm'`, and the two engine copies interpolated a bare `undefined` where the CLI copy rendered a real fallback. `describeFlowContender` sat exactly where a shared renderer would live and read like one, but it is a CLASSIFIER with no prose in it — so each caller wrote its own sentence while an export that looked reusable sat beside them. That is the durable part of the defect: a missing renderer is a gap someone fills, a misleading one is a gap that keeps getting re-filled privately. The two copies in this package now call one exported `renderFlowContender`, and `describeFlowContender`'s own docs say what it is not. Both spellings were decided on measurement: - Single quotes, measured against this package rather than voted across the three copies: 203 single-quoted interpolations in operator prose under `service-automation/src` against 3 double-quoted, one of which was this phrase. The sentence already single-quotes the flow name beside it. - A named fallback instead of `package 'undefined'`. This package's callers cannot reach that branch today (`isCodeArtifactBody` is false on a falsy `_packageId`) — but that is a property of today's callers, not of an exported function. The third copy, in `@objectstack/cli`'s startup banner, deliberately keeps its own structural rendering: that package takes no static value import of `@objectstack/service-automation`, and its engine reads are feature-detected so a host on an older automation package still boots its banner. It is held equal by a test-only agreement pin instead, which is red in both directions. Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c0d0c3 commit 0d7b1f3

6 files changed

Lines changed: 301 additions & 12 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
---
4+
5+
fix(service-automation): one renderer for the contested-flow phrase, and the two spellings it had drifted into (#12563)
6+
7+
`minor`, not `patch`, and not empty: this adds a new export
8+
(`renderFlowContender`) to a published package's public API, and it changes
9+
**shipped operator-facing log text**. Both are real changes a consumer can
10+
observe.
11+
12+
## What changed
13+
14+
One event — a flow name claimed by more than one definition — was described to
15+
an operator in three places, each with its own private `const describe` beside
16+
the log call: `flow-precedence.ts`'s precedence warning, `plugin.ts`'s bootstrap
17+
audit, and (in `@objectstack/cli`) the startup banner. Nothing held them equal,
18+
and two axes had already drifted:
19+
20+
- **Quoting.** `flow-precedence.ts` rendered `package "crm"`; the other two
21+
rendered `package 'crm'`.
22+
- **Absent package id.** The two engine copies interpolated a bare `undefined`
23+
into the sentence; the CLI copy rendered a real fallback.
24+
25+
The two copies in this package are now one exported renderer. The choice on
26+
each axis was measured, not voted:
27+
28+
- **Single quotes**, measured against this package rather than across the three
29+
copies: of the interpolated identifiers in operator prose under
30+
`service-automation/src`, 203 are single-quoted and 3 double-quoted — one of
31+
those 3 being this phrase. The sentence already single-quotes the flow name
32+
beside it.
33+
- **A named fallback** (`a code-shipped package (id unknown)`) instead of
34+
`package 'undefined'`. This package's own callers cannot reach that branch
35+
today, because `isCodeArtifactBody` is false on a falsy `_packageId` — but
36+
that is a property of today's callers, not of an exported function.
37+
38+
## Log text a consumer may be matching on
39+
40+
`[Automation] Flow name collision: …` (the precedence warning) now renders a
41+
packaged contender as `package 'crm'` rather than `package "crm"`.
42+
`plugin.ts`'s bootstrap `[Automation] flow '<name>' is claimed by …` warning is
43+
byte-identical to before for every input its callers can produce; only its
44+
unreachable absent-id branch changed.
45+
46+
## Why the CLI still renders its own
47+
48+
`@objectstack/cli` deliberately keeps its own spelling and takes no value
49+
import of this package for the banner: its engine reads are structural and
50+
feature-detected so a host on an older automation package still boots. The
51+
third copy is held equal by a test-only agreement pin
52+
(`packages/cli/src/utils/format.flow-contender-agreement.test.ts`) that asserts
53+
the banner line through this renderer, so it goes red in both directions.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#12563] THE CLI's COPY OF THE CONTESTED-FLOW PHRASE, HELD EQUAL TO THE
4+
// ENGINE's — by agreement, not by a shared runtime import.
5+
//
6+
// ## What was wrong
7+
//
8+
// One event — a flow name claimed by more than one definition — was described
9+
// to an operator in three places, each with its own private `const describe`:
10+
// twice inside `@objectstack/service-automation` and once here, in the startup
11+
// banner. Nothing held them equal, and two axes had ALREADY drifted before
12+
// anyone noticed: the engine's precedence warning double-quoted the package id
13+
// while the other two single-quoted it, and the two engine copies interpolated
14+
// a bare `undefined` where this one renders a real fallback.
15+
//
16+
// The two engine copies are now one exported renderer, `renderFlowContender`.
17+
// This file is what holds the THIRD copy — the one in this package — to it.
18+
//
19+
// ## ⛔ Why this is a TEST-only import and `format.ts` still renders its own
20+
//
21+
// The obvious fix is for `format.ts` to import the renderer and call it. It is
22+
// the wrong fix here, and the reason is measured rather than stylistic:
23+
//
24+
// - `packages/cli` takes ZERO static value imports of
25+
// `@objectstack/service-automation` today. Its only value import of that
26+
// package is a DYNAMIC `await import()` in `utils/data-migration-plugins.ts`,
27+
// deferred behind `opts.automation === true`.
28+
// - `collectAutomationSummary` reads the engine STRUCTURALLY and feature-
29+
// detects every probe (#12028/#12562) precisely so a host running an OLDER
30+
// automation package still boots its banner. A value import states a
31+
// guarantee that runtime deliberately does not make.
32+
// - `utils/format.ts` is imported by ~56 command modules in this package. A
33+
// static import here would pull the whole automation package into the
34+
// module graph of `os whoami`, `os init`, `os login` — every command.
35+
//
36+
// A TEST is not the shipped runtime path, and `@objectstack/service-automation`
37+
// is already a workspace dependency, so importing the renderer HERE costs none
38+
// of that. The banner keeps its own spelling; this file is what makes the two
39+
// spellings a fact rather than a coincidence.
40+
//
41+
// ⚠️ This specifier resolves through the package's `exports` to its **dist**,
42+
// not its source — it is already registered for this package in
43+
// `KNOWN_UNALIASED_TEST_IMPORTS['@objectstack/cli']` (that shrink-only ledger is
44+
// NOT widened by this file; the specifier was already reachable via the dynamic
45+
// import named above). `turbo.json` declares `@objectstack/cli#test`
46+
// `dependsOn: ["build"]`, so CI builds it first. Locally: build
47+
// `@objectstack/service-automation` before running this, or the named import
48+
// below fails to link and says so.
49+
//
50+
// ⛔ Deliberately NOT aliased to source in `vitest.config.ts`. Aliasing a dep to
51+
// source imports that dep's ENTIRE import surface into this package's
52+
// resolution domain (see `scripts/check-test-source-alias.mjs` and this
53+
// package's vitest config header) — a config change reaching all ~185 test
54+
// files here, to buy staleness-resistance on one three-branch pure function.
55+
56+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
57+
import { renderFlowContender } from '@objectstack/service-automation';
58+
import { printServerReady, type ServerReadyOptions, type AutomationReadySummary } from './format.js';
59+
60+
type Contender = { source: 'package' | 'runtime'; packageId?: string };
61+
62+
const base: ServerReadyOptions = {
63+
externalBaseOrigin: 'http://localhost:3000',
64+
configFile: 'objectstack.config.ts',
65+
isDev: true,
66+
pluginCount: 1,
67+
};
68+
69+
/**
70+
* The banner summary shaped by hand rather than through
71+
* `collectAutomationSummary`: this file is about the RENDERING of one contender,
72+
* so the collection step is deliberately not in the loop. The end-to-end path
73+
* from a fake engine through the collector is covered by
74+
* `commands/serve-automation-shadowing.test.ts`.
75+
*/
76+
const summaryWith = (armed: Contender, shadowedCount = 1): AutomationReadySummary => ({
77+
enabled: true,
78+
declaredFlowCount: 1,
79+
flowCount: 1,
80+
boundCount: 1,
81+
triggerTypes: ['record_change'],
82+
unbound: [],
83+
unknownObject: [],
84+
draftCount: 0,
85+
shadowed: [{ flowName: 'send-welcome', armed, shadowedCount }],
86+
});
87+
88+
describe('#12563 — the banner phrase for a contested flow agrees with the engine renderer', () => {
89+
let lines: string[];
90+
let spy: ReturnType<typeof vi.spyOn>;
91+
92+
beforeEach(() => {
93+
lines = [];
94+
// stderr, not stdout (#7915) — the whole banner is a diagnostic.
95+
spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
96+
lines.push(args.join(' '));
97+
});
98+
});
99+
afterEach(() => spy.mockRestore());
100+
101+
const shadowLine = (armed: Contender) => {
102+
printServerReady({ ...base, automation: summaryWith(armed) });
103+
const shown = lines.filter((l) => l.includes('is claimed by'));
104+
expect(shown).toHaveLength(1);
105+
return shown[0];
106+
};
107+
108+
// One row per branch of the renderer, so a drift on ONE axis fails the row
109+
// for that axis instead of collapsing the file. The assertion is
110+
// `toContain(renderFlowContender(c))`, which is red in BOTH directions: it
111+
// fails if this package's `describeFlowBody` changes, and it fails if the
112+
// engine's renderer changes underneath it.
113+
it('spells a packaged contender exactly as the engine spells it', () => {
114+
const armed: Contender = { source: 'package', packageId: 'crm' };
115+
expect(shadowLine(armed)).toContain(renderFlowContender(armed));
116+
});
117+
118+
it('spells a runtime overlay row exactly as the engine spells it', () => {
119+
const armed: Contender = { source: 'runtime' };
120+
expect(shadowLine(armed)).toContain(renderFlowContender(armed));
121+
});
122+
123+
it('spells an absent package id exactly as the engine spells it — and never as `undefined`', () => {
124+
const armed: Contender = { source: 'package' };
125+
const line = shadowLine(armed);
126+
expect(line).toContain(renderFlowContender(armed));
127+
// The axis this pin exists for, stated independently of the renderer: if
128+
// BOTH sides regressed to interpolation at once the agreement above would
129+
// still hold, and this row would not.
130+
expect(line).not.toContain('undefined');
131+
});
132+
133+
// ── The instrument can say no ─────────────────────────────────────────────
134+
it('would notice a disagreement — the comparison is not vacuous', () => {
135+
const armed: Contender = { source: 'package', packageId: 'crm' };
136+
const line = shadowLine(armed);
137+
// The pre-#12563 engine spelling of the SAME contender. If `toContain`
138+
// could not tell the two apart, every row above would pass no matter what
139+
// either side rendered.
140+
expect(line).not.toContain(`package "${armed.packageId}"`);
141+
expect(renderFlowContender(armed)).not.toBe(`package "${armed.packageId}"`);
142+
});
143+
});

packages/services/service-automation/src/flow-name-shadowing.test.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import { describe, it, expect, vi } from 'vitest';
2727
import { SchemaRegistry } from '@objectstack/objectql';
2828
import { AutomationEngine } from './engine.js';
29-
import { resolveFlowPrecedence, describeFlowContender } from './flow-precedence.js';
29+
import { resolveFlowPrecedence, describeFlowContender, renderFlowContender } from './flow-precedence.js';
3030

3131
const FLOW = 'opportunity_approval';
3232
const silentLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any;
@@ -121,7 +121,14 @@ describe('#11997 — packaged flow shadowed by a same-named runtime flow', () =>
121121
const [message, meta] = warn.mock.calls[0] as [string, any];
122122

123123
expect(message).toContain(FLOW); // the bare name
124-
expect(message).toContain('package "crm"'); // contender A
124+
// ⚠️ Contender A is asserted THROUGH the renderer, not as a literal
125+
// (#12563). This warning used to spell the phrase itself, in a private
126+
// `const describe` two lines above the call — with double quotes, while
127+
// the two other copies of the same sentence used single. Deriving the
128+
// expectation here means re-introducing a private spelling at this call
129+
// site fails THIS row; the renderer's own literal output is pinned
130+
// separately below, so the two cannot drift together and stay green.
131+
expect(message).toContain(renderFlowContender({ source: 'package', packageId: 'crm' })); // contender A
125132
expect(message).toContain('runtime-authored row'); // contender B
126133
expect(message).toContain('arming a runtime-authored row'); // which one wins
127134
expect(message).toContain('ADR-0005');
@@ -219,3 +226,48 @@ describe('#11997 — precedence is a total order, not an iteration order', () =>
219226
expect((resolved[0].definition as any).label).toBe('TENANT');
220227
});
221228
});
229+
230+
/**
231+
* [#12563] The phrase itself, pinned as literals.
232+
*
233+
* The call sites above and the CLI banner assert THROUGH `renderFlowContender`,
234+
* so a caller that re-invents the phrase privately goes red. That alone is not
235+
* enough: if the renderer's own spelling changed, every derived assertion would
236+
* move with it and stay green. These three rows are the anchor that cannot
237+
* move quietly — one per decision the renderer makes.
238+
*/
239+
describe('#12563 — renderFlowContender: one spelling for the contested-flow phrase', () => {
240+
it('quotes a package id the way this package quotes identifiers — single, not double', () => {
241+
// ⛔ Not a majority vote of the three copies that used to exist. Measured
242+
// over `service-automation/src`: 203 single-quoted interpolations in
243+
// operator prose against 3 double-quoted, one of which WAS this phrase.
244+
// The sentence already single-quotes the flow name beside this.
245+
expect(renderFlowContender({ source: 'package', packageId: 'crm' })).toBe("package 'crm'");
246+
expect(renderFlowContender({ source: 'package', packageId: 'com.objectstack.platform-objects' })).toBe(
247+
"package 'com.objectstack.platform-objects'",
248+
);
249+
});
250+
251+
it('names a runtime overlay by the table an admin would go edit', () => {
252+
expect(renderFlowContender({ source: 'runtime' })).toBe('a runtime-authored row (sys_metadata)');
253+
// A runtime row bound to a real package id is STILL a runtime row — the
254+
// id is not part of this branch's phrase, and leaking it here would
255+
// read as "a package shipped this", the opposite of what happened.
256+
expect(renderFlowContender({ source: 'runtime', packageId: 'crm' })).toBe(
257+
'a runtime-authored row (sys_metadata)',
258+
);
259+
});
260+
261+
it('never interpolates an absent package id into the sentence', () => {
262+
// ⚠️ Unreachable from THIS package's callers today — `isCodeArtifactBody`
263+
// is false on a falsy `_packageId`, so `describeFlowContender` never
264+
// emits a package contender without one. That is a property of today's
265+
// callers, not of the renderer, and this row is what keeps the renderer
266+
// safe for the next one. It is a real branch of an exported function.
267+
expect(renderFlowContender({ source: 'package' })).toBe('a code-shipped package (id unknown)');
268+
expect(renderFlowContender({ source: 'package' })).not.toContain('undefined');
269+
expect(renderFlowContender({ source: 'package', packageId: '' })).toBe(
270+
'a code-shipped package (id unknown)',
271+
);
272+
});
273+
});

packages/services/service-automation/src/flow-precedence.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ export interface FlowPrecedenceWinner {
6060
/**
6161
* Classify one registry body's provenance.
6262
*
63+
* ⚠️ This is a CLASSIFIER, not a renderer. It answers "where did this body come
64+
* from?" with a {@link FlowContender} and carries no prose at all — yet it is
65+
* exported, and it sits exactly where a shared renderer would live. It has been
66+
* mistaken for one: three separate callers each wrote their own private
67+
* sentence about a contested flow name, because the export that looked
68+
* reusable had nothing in it to reuse. For the operator-facing phrase, use
69+
* {@link renderFlowContender} below.
70+
*
6371
* Delegates to `isCodeArtifactBody` — the canonical ADR-0029 D9.6 test, which
6472
* exists precisely so callers cannot drift into a second answer to "does a code
6573
* package ship this name?". ⛔ Do not re-derive this from `_packageId`: that
@@ -78,6 +86,43 @@ export function describeFlowContender(item: unknown): FlowContender {
7886
};
7987
}
8088

89+
/**
90+
* Render one contender as the phrase an operator reads.
91+
*
92+
* ⛔ The ONLY place this phrase is spelled. Before it existed the same sentence
93+
* was written three times from scratch — twice in this package (the pull
94+
* warning below, and the plugin's bootstrap audit) and once in
95+
* `@objectstack/cli`'s startup banner — and the copies had already drifted on
96+
* TWO axes. A private `const describe = …` beside a log call is how each copy
97+
* arrived; reach for this instead, and a fourth caller costs nothing.
98+
*
99+
* ## Both spellings are decisions, so they are recorded here
100+
*
101+
* **Single quotes** — measured against this package, ⛔ not voted across the
102+
* copies. Of the interpolated identifiers in operator prose under
103+
* `service-automation/src`, 203 are single-quoted and 3 double-quoted, and one
104+
* of those 3 was this phrase. The sentence this phrase lands in already
105+
* single-quotes the flow NAME, which is the more free-form of the two values,
106+
* so single quotes here add no ambiguity the line does not already carry.
107+
* `packageId` is an unconstrained `z.string()` in `packages/spec`, so neither
108+
* spelling is provably safe against an adversarial id — this one is at least
109+
* the house convention rather than a coin flip.
110+
*
111+
* **A named fallback, never an interpolated `undefined`.** `packageId` is
112+
* optional on {@link FlowContender}, and `package 'undefined'` is the one
113+
* rendering an operator cannot act on. This package's own callers cannot reach
114+
* that branch today — `isCodeArtifactBody` is false on a falsy `_packageId`,
115+
* so a `source: 'package'` contender always carries one — but that is a
116+
* property of today's CALLERS, not of this function. A renderer that is safe
117+
* only because of who happens to call it stops being safe at the next caller.
118+
*/
119+
export function renderFlowContender(contender: FlowContender): string {
120+
if (contender.source !== 'package') return 'a runtime-authored row (sys_metadata)';
121+
return contender.packageId
122+
? `package '${contender.packageId}'`
123+
: 'a code-shipped package (id unknown)';
124+
}
125+
81126
/**
82127
* Rank one contender for a bare name. LOWER wins.
83128
*
@@ -147,13 +192,10 @@ export function resolveFlowPrecedence(
147192

148193
const armed = ranked[0];
149194
const shadowed = ranked.slice(1).map((entry) => entry.contender);
150-
const describe = (c: FlowContender) =>
151-
c.source === 'package' ? `package "${c.packageId}"` : 'a runtime-authored row (sys_metadata)';
152-
153195
logger?.warn(
154196
`[Automation] Flow name collision: '${name}' is claimed by ${group.length} definitions ` +
155-
`(${ranked.map((entry) => describe(entry.contender)).join(', ')}); ` +
156-
`arming ${describe(armed.contender)} per ADR-0005 overlay precedence and shadowing ` +
197+
`(${ranked.map((entry) => renderFlowContender(entry.contender)).join(', ')}); ` +
198+
`arming ${renderFlowContender(armed.contender)} per ADR-0005 overlay precedence and shadowing ` +
157199
`${shadowed.length} other definition(s). Only the armed definition dispatches. ` +
158200
`Rename one, or remove the sys_metadata row if the package value should win.`,
159201
{

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export type {
4040
// pull applies this; exported so a host that assembles its own flow list (or a
4141
// test) collapses contenders the same deterministic way instead of inventing a
4242
// second precedence.
43-
export { resolveFlowPrecedence, describeFlowContender } from './flow-precedence.js';
43+
export { resolveFlowPrecedence, describeFlowContender, renderFlowContender } from './flow-precedence.js';
4444
export type { FlowPrecedenceWinner } from './flow-precedence.js';
4545

4646
// Per-run summary (#4354): the fold that turns a run's step log into

0 commit comments

Comments
 (0)