@@ -21,6 +21,7 @@ import {
2121 type ScreenFieldVisibility,
2222} from './screen-input-contract.js';
2323import type { Logger } from '@objectstack/spec/contracts';
24+ import type { FlowUnboundCause } from '@objectstack/spec/contracts';
2425import { FLOW_HOOK_SECRET_KEY, flowCredentialClassLabel, flowCredentialPositions } from './flow-credential-projection.js';
2526import { 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 }
0 commit comments