Skip to content

Commit 2267a9d

Browse files
committed
feat(spec,automation): FlowRuntimeState.unboundCause classifies an unbound row beside its reason
FlowRuntimeState gains an optional, closed unboundCause (FLOW_UNBOUND_CAUSES: deployment_policy, disabled_subflow, missing_trigger, binding_failure), stamped by AutomationEngine.describeUnboundReason on exactly the rows that carry reason, so a consumer classifies without parsing the sentence. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU9ijL7gdKme3dFxWKcBE6
1 parent 3fed1eb commit 2267a9d

5 files changed

Lines changed: 136 additions & 12 deletions

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3573,6 +3573,8 @@ describe('AutomationEngine - the deployment switch (#17396)', () => {
35733573
expect(row?.reason).toBe(SCHEDULED_WORK_DISABLED_REASON);
35743574
expect(row?.reason).not.toMatch(/binding failed/);
35753575
expect(row?.reason).toContain(SCHEDULED_WORK_ENV);
3576+
// [#23842] Classified without parsing the sentence: an operator remedy.
3577+
expect(row?.unboundCause).toBe('deployment_policy');
35763578
});
35773579

35783580
it('the two doors answer the SAME sentence for the same flow — one computation, no drift', () => {
@@ -3607,6 +3609,10 @@ describe('AutomationEngine - the deployment switch (#17396)', () => {
36073609
// And they really are two different sentences — an identity between two
36083610
// constants would pass vacuously.
36093611
expect(new Set(audit.map((a) => a.reason)).size).toBe(2);
3612+
// [#23842] ...and two different causes, stamped where each branch is
3613+
// decided: no `record_change` trigger is registered on this kernel.
3614+
expect(states.find((s) => s.name === 'digest')?.unboundCause).toBe('deployment_policy');
3615+
expect(states.find((s) => s.name === 'missing_trigger')?.unboundCause).toBe('missing_trigger');
36103616
});
36113617

36123618
it('the status row reports what HAPPENED, not what the environment says when it is read', () => {
@@ -3642,6 +3648,8 @@ describe('AutomationEngine - the deployment switch (#17396)', () => {
36423648
const row = engine.getFlowRuntimeStates().find((s) => s.name === 'digest');
36433649
expect(row?.reason).toMatch(/binding failed/);
36443650
expect(row?.reason).not.toBe(SCHEDULED_WORK_DISABLED_REASON);
3651+
// [#23842] A defect, classified as one — ⛔ never `deployment_policy`.
3652+
expect(row?.unboundCause).toBe('binding_failure');
36453653
});
36463654

36473655
it('⭐ DARK: a row with nothing to explain carries NO reason key at all', () => {
@@ -3655,14 +3663,32 @@ describe('AutomationEngine - the deployment switch (#17396)', () => {
36553663
engine.registerTrigger(recordingTrigger('record_change').trigger);
36563664
engine.registerFlow('digest', scheduleFlow('digest')); // bound
36573665
engine.registerFlow('rc_obsolete', { ...recordChangeFlow('rc_obsolete'), status: 'obsolete' }); // disabled
3666+
engine.registerFlow('by_hand', { // manual
3667+
name: 'by_hand',
3668+
label: 'by_hand',
3669+
type: 'autolaunched' as const,
3670+
nodes: [
3671+
{ id: 'start', type: 'start' as const, label: 'Start' },
3672+
{ id: 'end', type: 'end' as const, label: 'End' },
3673+
],
3674+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
3675+
});
36583676

36593677
const states = engine.getFlowRuntimeStates();
36603678
const bound = states.find((s) => s.name === 'digest');
36613679
const disabled = states.find((s) => s.name === 'rc_obsolete');
3680+
const manual = states.find((s) => s.name === 'by_hand');
36623681
expect(bound).toMatchObject({ enabled: true, bound: true });
36633682
expect(disabled).toMatchObject({ enabled: false, bound: false });
3683+
expect(manual, 'control: the manual flow is listed, unbound, with no trigger').toMatchObject({ enabled: true, bound: false });
3684+
expect(manual?.triggerType).toBeUndefined();
36643685
expect(Object.keys(bound ?? {}), 'a bound flow explains nothing').not.toContain('reason');
36653686
expect(Object.keys(disabled ?? {}), '`enabled: false` already says it').not.toContain('reason');
3687+
// [#23842] The classifier is paired with the sentence: absent together.
3688+
for (const row of [bound, disabled, manual]) {
3689+
expect(Object.keys(row ?? {}), `'${row?.name}' carries no reason`).not.toContain('reason');
3690+
expect(Object.keys(row ?? {}), `'${row?.name}' carries no unboundCause`).not.toContain('unboundCause');
3691+
}
36663692
expect(engine.getTriggerBindingAudit(), 'control: the sibling door is empty for the same reason').toHaveLength(0);
36673693
});
36683694
});

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

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
type ScreenFieldVisibility,
2222
} from './screen-input-contract.js';
2323
import type { Logger } from '@objectstack/spec/contracts';
24+
import type { FlowUnboundCause } from '@objectstack/spec/contracts';
2425
import { FLOW_HOOK_SECRET_KEY, flowCredentialClassLabel, flowCredentialPositions } from './flow-credential-projection.js';
2526
import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor, DecisionConfigSchema } from '@objectstack/spec/automation';
2627
// [#14328] The ONE answer to "which trigger kind does this flow ask for?" —
@@ -4850,6 +4851,7 @@ export class AutomationEngine implements IAutomationService {
48504851
triggerType?: string;
48514852
object?: string;
48524853
reason?: string;
4854+
unboundCause?: FlowUnboundCause;
48534855
armedFrom?: FlowContender;
48544856
shadowed?: FlowContender[];
48554857
}> {
@@ -4859,7 +4861,7 @@ export class AutomationEngine implements IAutomationService {
48594861
// reads. This map holds ONE entry per bare name, so without these
48604862
// two fields a displaced contender leaves no trace on this surface.
48614863
const shadowing = this.flowShadowing.get(name);
4862-
const reason = this.describeUnboundReason(name, resolved);
4864+
const unbound = this.describeUnboundReason(name, resolved);
48634865
return {
48644866
name,
48654867
enabled: this.isFlowEnabled(name),
@@ -4870,7 +4872,11 @@ export class AutomationEngine implements IAutomationService {
48704872
// Absent, not `undefined`-valued: a row that is bound, disabled
48714873
// or trigger-less has no reason to carry, and `reason: null` on
48724874
// the wire would read as "we looked and found nothing".
4873-
...(reason === undefined ? {} : { reason }),
4875+
// [#23842] `unboundCause` rides the SAME spread, so the pair is
4876+
// present together or absent together by construction.
4877+
...(unbound === undefined
4878+
? {}
4879+
: { reason: unbound.reason, unboundCause: unbound.cause }),
48744880
...(shadowing
48754881
? { armedFrom: shadowing.armed, shadowed: shadowing.shadowed }
48764882
: {}),
@@ -4886,6 +4892,11 @@ export class AutomationEngine implements IAutomationService {
48864892
* drift apart. `undefined` means this flow has no unbound reason to report:
48874893
* it is bound, it is disabled, or it declares no trigger at all.
48884894
*
4895+
* [#23842] Each branch answers its sentence AND its closed
4896+
* `FlowUnboundCause` together, so the status door can let a consumer
4897+
* classify a row without parsing the sentence — one value per branch,
4898+
* stamped where the branch is decided, never inferred from the text.
4899+
*
48894900
* The POLICY branch outranks both binding branches, and deliberately so.
48904901
* When package-authored scheduled work is off, neither of the other two
48914902
* reasons is true in any useful sense: the trigger was never called, so
@@ -4912,21 +4923,27 @@ export class AutomationEngine implements IAutomationService {
49124923
private describeUnboundReason(
49134924
name: string,
49144925
resolved: { triggerType: string } | undefined,
4915-
): string | undefined {
4926+
): { reason: string; cause: FlowUnboundCause } | undefined {
49164927
if (!resolved) return undefined; // manual / screen flow — nothing to bind
49174928
if (!this.isFlowEnabled(name)) return undefined;
49184929
if (this.boundFlowTriggers.has(name)) return undefined;
49194930
const policyReason = this.policyDisabledFlows.get(name);
4920-
if (policyReason !== undefined) return policyReason;
4931+
if (policyReason !== undefined) return { reason: policyReason, cause: 'deployment_policy' };
49214932
// [#20725, ADR-0126 §7.3] The arming gate declined it onto a disabled
49224933
// packaged subflow — read from the record, for the policy line's
49234934
// reason, and ahead of the trigger branches for the gate's: with the
49244935
// child off, the trigger would not arm it either.
49254936
const declined = this.subflowDeclinedFlows.get(name);
4926-
if (declined !== undefined) return declined;
4937+
if (declined !== undefined) return { reason: declined, cause: 'disabled_subflow' };
49274938
return this.triggers.has(resolved.triggerType)
4928-
? `trigger '${resolved.triggerType}' is registered but binding failed — see earlier warnings`
4929-
: `no '${resolved.triggerType}' trigger is registered — add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)`;
4939+
? {
4940+
reason: `trigger '${resolved.triggerType}' is registered but binding failed — see earlier warnings`,
4941+
cause: 'binding_failure',
4942+
}
4943+
: {
4944+
reason: `no '${resolved.triggerType}' trigger is registered — add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)`,
4945+
cause: 'missing_trigger',
4946+
};
49304947
}
49314948

49324949
/**
@@ -4946,9 +4963,9 @@ export class AutomationEngine implements IAutomationService {
49464963
// trigger) and the three-branch vocabulary both live in
49474964
// `describeUnboundReason` now, so this audit and the status door
49484965
// report the SAME sentence for the same flow by construction.
4949-
const reason = this.describeUnboundReason(name, resolved);
4950-
if (reason === undefined || !resolved) continue;
4951-
audit.push({ flowName: name, triggerType: resolved.triggerType, reason });
4966+
const unbound = this.describeUnboundReason(name, resolved);
4967+
if (unbound === undefined || !resolved) continue;
4968+
audit.push({ flowName: name, triggerType: resolved.triggerType, reason: unbound.reason });
49524969
}
49534970
return audit;
49544971
}

‎packages/services/service-automation/src/subflow-guard-every-door.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ describe('ADR-0126 §7.3 at REGISTRATION — the arming gate declines a packaged
185185
expect(row).toMatchObject({ enabled: true, bound: false });
186186
expect(row?.reason).toContain("'shared_step'");
187187
expect(row?.reason).toContain('ADR-0126 §7.3');
188+
// [#23842] The classifier rides beside the sentence: an admin remedy
189+
// (enable the subflow), ⛔ neither a policy nor a binding defect.
190+
expect(row?.unboundCause).toBe('disabled_subflow');
188191
const declined = declineWarnings(logger, 'vendor_process');
189192
expect(declined).toHaveLength(1);
190193
expect(declined[0]).toContain("'shared_step' (switched off in the activation ledger)");
@@ -230,6 +233,7 @@ describe('ADR-0126 §7.3 at REGISTRATION — the arming gate declines a packaged
230233

231234
expect(trigger.isBound('vendor_process')).toBe(true);
232235
expect(stateOf(engine, 'vendor_process')?.reason).toBeUndefined();
236+
expect(Object.keys(stateOf(engine, 'vendor_process') ?? {}), '[#23842] armed: the cause clears with the sentence').not.toContain('unboundCause');
233237
expect((await engine.execute('vendor_process')).success).toBe(true);
234238
});
235239

‎packages/spec/src/contracts/automation-service.test.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { readFileSync } from 'node:fs';
22
import { fileURLToPath } from 'node:url';
33

44
import { describe, it, expect } from 'vitest';
5-
import type { IAutomationService, AutomationResult, FlowRuntimeState } from './automation-service';
5+
import type { IAutomationService, AutomationResult, FlowRuntimeState, FlowUnboundCause } from './automation-service';
6+
import { FLOW_UNBOUND_CAUSES } from './automation-service';
67
import type { FlowParsed } from '../automation/flow.zod';
78
import { FlowSchema } from '../automation/flow.zod';
89
import type { ExecutionLog } from '../automation/execution.zod';
@@ -49,6 +50,18 @@ export type FlowRuntimeStateCarriesAnOptionalReason = Assert<Eq<FlowRuntimeState
4950
export type FlowRuntimeStateReasonIsOptional = Assert<
5051
{ name: string; enabled: boolean; bound: boolean } extends FlowRuntimeState ? true : false
5152
>;
53+
/**
54+
* [#23842] The classifier beside the sentence: optional, and CLOSED over the
55+
* exported tuple, so a consumer can enumerate it and a producer cannot stamp a
56+
* value the tuple does not list. The identity fails if the key is dropped,
57+
* widened to `string`, or drifts from `FLOW_UNBOUND_CAUSES`.
58+
*/
59+
export type FlowRuntimeStateUnboundCauseIsTheClosedTuple = Assert<
60+
Eq<FlowRuntimeState['unboundCause'], (typeof FLOW_UNBOUND_CAUSES)[number] | undefined>
61+
>;
62+
export type FlowUnboundCauseIsTheTupleUnion = Assert<
63+
Eq<FlowUnboundCause, 'deployment_policy' | 'disabled_subflow' | 'missing_trigger' | 'binding_failure'>
64+
>;
5265

5366
describe('Automation Service Contract', () => {
5467
it('should allow a minimal IAutomationService implementation with required methods', () => {
@@ -457,4 +470,27 @@ describe('FlowRuntimeState — carries the reason a flow is unbound', () => {
457470
expect(doc).toContain('RECORDED refusal');
458471
expect(doc).toContain('getTriggerBindingAudit()');
459472
});
473+
474+
it('[#23842] classifies through a closed tuple beside the sentence, and the sentence stays render-only', () => {
475+
// One machine value per cause, snake_case, no duplicates, no catch-all.
476+
expect(new Set(FLOW_UNBOUND_CAUSES).size).toBe(FLOW_UNBOUND_CAUSES.length);
477+
for (const cause of FLOW_UNBOUND_CAUSES) expect(cause).toMatch(/^[a-z]+(_[a-z]+)*$/);
478+
expect(FLOW_UNBOUND_CAUSES).not.toContain('other');
479+
const policyRow: FlowRuntimeState = {
480+
name: 'daily_digest',
481+
enabled: true,
482+
bound: false,
483+
triggerType: 'schedule',
484+
reason: 'disabled by deployment policy',
485+
unboundCause: 'deployment_policy',
486+
};
487+
expect(FLOW_UNBOUND_CAUSES).toContain(policyRow.unboundCause);
488+
489+
const source = readFileSync(fileURLToPath(new URL('./automation-service.ts', import.meta.url)), 'utf8');
490+
const block = source.slice(source.indexOf('export interface FlowRuntimeState'));
491+
const doc = block.slice(0, block.indexOf('\n}'));
492+
// `reason` still forbids parsing, and now names where classification lives.
493+
expect(doc).toContain('do not parse it');
494+
expect(doc).toContain('{@link FlowRuntimeState.unboundCause}');
495+
});
460496
});

‎packages/spec/src/contracts/automation-service.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,8 @@ export interface FlowRuntimeState {
620620
* Whether a trigger is actually wired to this flow. `false` for a flow with
621621
* no declared trigger (manual / screen flows) AND for one whose declared
622622
* trigger type has no registered trigger — `triggerType` distinguishes the
623-
* two.
623+
* two, and on an enabled, triggered row {@link FlowRuntimeState.unboundCause}
624+
* names which unbound cause applies.
624625
*/
625626
bound: boolean;
626627
/** Persisted deployment status, when the flow carries one. */
@@ -658,10 +659,50 @@ export interface FlowRuntimeState {
658659
*
659660
* A free-form sentence, deliberately — the shape the two surfaces that
660661
* already carry this reason use. Consumers RENDER it; ⛔ do not parse it.
662+
* A consumer that must CLASSIFY the row (style a policy row apart from a
663+
* defect) reads {@link FlowRuntimeState.unboundCause}, which rides beside
664+
* this sentence on exactly the same rows.
661665
*/
662666
reason?: string;
667+
/**
668+
* WHICH cause {@link FlowRuntimeState.reason} describes, as a closed
669+
* machine value — one of {@link FLOW_UNBOUND_CAUSES}. Present exactly on
670+
* the rows that carry `reason`, absent on every other row (bound,
671+
* disabled, or declaring no trigger), so a consumer classifies without
672+
* parsing the sentence and renders the sentence without branching on it.
673+
*/
674+
unboundCause?: FlowUnboundCause;
663675
}
664676

677+
/**
678+
* Every distinct cause the automation engine reports for an ENABLED,
679+
* trigger-declaring flow that is `bound: false` — one value per cause the
680+
* producer actually composes, each with its own owner and remedy, so a value
681+
* is never a catch-all:
682+
*
683+
* - `deployment_policy` — package-authored scheduled work is switched off on
684+
* this deployment (the `ScheduledWorkPolicy` refusal, read from the
685+
* recorded refusal). Operator remedy: the deployment switch or the host.
686+
* ⛔ Not a defect.
687+
* - `disabled_subflow` — the arming gate declined the flow because a packaged
688+
* subflow it calls is disabled (ADR-0126 §7.3). Admin remedy: enable the
689+
* subflow, and the caller arms.
690+
* - `missing_trigger` — no trigger of the declared type is registered on this
691+
* kernel. Author remedy: depend on the trigger package
692+
* (`requires: ['triggers']`).
693+
* - `binding_failure` — the trigger is registered but binding the flow to it
694+
* failed. Engineering remedy: the earlier boot warnings name the error.
695+
*/
696+
export const FLOW_UNBOUND_CAUSES = [
697+
'deployment_policy',
698+
'disabled_subflow',
699+
'missing_trigger',
700+
'binding_failure',
701+
] as const;
702+
703+
/** Why an enabled, triggered flow is not armed — derived from {@link FLOW_UNBOUND_CAUSES}. */
704+
export type FlowUnboundCause = (typeof FLOW_UNBOUND_CAUSES)[number];
705+
665706
/**
666707
* One window of execution runs, plus the truncation fact the window alone
667708
* cannot carry (#19543).

0 commit comments

Comments
 (0)