Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/23842-automation-stamps-unbound-cause.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@objectstack/service-automation': minor
---

feat(automation): `getFlowRuntimeStates()` stamps `unboundCause` beside `reason`

Clause-②: yes (widening)

Each branch that decides why an enabled, triggered flow is not armed now answers its closed `FlowUnboundCause` together with its sentence: `deployment_policy`, `disabled_subflow`, `missing_trigger` or `binding_failure`. `GET /automation/_status` passes the rows through unchanged, so the field reaches the console with no route change. Bound, disabled and trigger-less rows carry neither `reason` nor `unboundCause`. The sentences and `getTriggerBindingAudit()` are unchanged.
18 changes: 18 additions & 0 deletions .changeset/23842-flow-runtime-state-unbound-cause.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': minor
---

feat(spec): `FlowRuntimeState` gains `unboundCause`, a closed classifier beside `reason`

Clause-②: yes (widening)

`FlowRuntimeState.reason` is a sentence a consumer renders and must not parse, so until now a console could not tell a flow left unarmed by deployment policy from one whose binding is broken: both arrive as `enabled: true, bound: false` with a `reason`. The row now also carries an optional `unboundCause`, present on exactly the rows that carry `reason` and absent on every other row.

Its values are published as `FLOW_UNBOUND_CAUSES` (`@objectstack/spec/contracts`), with the derived type `FlowUnboundCause`:

- `deployment_policy`: package-authored scheduled work is switched off on this deployment. An operator remedy, not a defect.
- `disabled_subflow`: the flow calls a packaged subflow that is disabled (ADR-0126 §7.3). Enable the subflow and the caller arms.
- `missing_trigger`: no trigger of the declared type is registered. Depend on the trigger package (`requires: ['triggers']`).
- `binding_failure`: the trigger is registered but binding the flow to it failed. The earlier boot warnings name the error.

Additive: existing producers and consumers keep working unchanged, and `reason` keeps its text.
26 changes: 26 additions & 0 deletions packages/services/service-automation/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3573,6 +3573,8 @@ describe('AutomationEngine - the deployment switch (#17396)', () => {
expect(row?.reason).toBe(SCHEDULED_WORK_DISABLED_REASON);
expect(row?.reason).not.toMatch(/binding failed/);
expect(row?.reason).toContain(SCHEDULED_WORK_ENV);
// [#23842] Classified without parsing the sentence: an operator remedy.
expect(row?.unboundCause).toBe('deployment_policy');
});

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

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

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

const states = engine.getFlowRuntimeStates();
const bound = states.find((s) => s.name === 'digest');
const disabled = states.find((s) => s.name === 'rc_obsolete');
const manual = states.find((s) => s.name === 'by_hand');
expect(bound).toMatchObject({ enabled: true, bound: true });
expect(disabled).toMatchObject({ enabled: false, bound: false });
expect(manual, 'control: the manual flow is listed, unbound, with no trigger').toMatchObject({ enabled: true, bound: false });
expect(manual?.triggerType).toBeUndefined();
expect(Object.keys(bound ?? {}), 'a bound flow explains nothing').not.toContain('reason');
expect(Object.keys(disabled ?? {}), '`enabled: false` already says it').not.toContain('reason');
// [#23842] The classifier is paired with the sentence: absent together.
for (const row of [bound, disabled, manual]) {
expect(Object.keys(row ?? {}), `'${row?.name}' carries no reason`).not.toContain('reason');
expect(Object.keys(row ?? {}), `'${row?.name}' carries no unboundCause`).not.toContain('unboundCause');
}
expect(engine.getTriggerBindingAudit(), 'control: the sibling door is empty for the same reason').toHaveLength(0);
});
});
37 changes: 27 additions & 10 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type ScreenFieldVisibility,
} from './screen-input-contract.js';
import type { Logger } from '@objectstack/spec/contracts';
import type { FlowUnboundCause } from '@objectstack/spec/contracts';
import { FLOW_HOOK_SECRET_KEY, flowCredentialClassLabel, flowCredentialPositions } from './flow-credential-projection.js';
import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor, DecisionConfigSchema } from '@objectstack/spec/automation';
// [#14328] The ONE answer to "which trigger kind does this flow ask for?" —
Expand Down Expand Up @@ -4850,6 +4851,7 @@ export class AutomationEngine implements IAutomationService {
triggerType?: string;
object?: string;
reason?: string;
unboundCause?: FlowUnboundCause;
armedFrom?: FlowContender;
shadowed?: FlowContender[];
}> {
Expand All @@ -4859,7 +4861,7 @@ export class AutomationEngine implements IAutomationService {
// reads. This map holds ONE entry per bare name, so without these
// two fields a displaced contender leaves no trace on this surface.
const shadowing = this.flowShadowing.get(name);
const reason = this.describeUnboundReason(name, resolved);
const unbound = this.describeUnboundReason(name, resolved);
return {
name,
enabled: this.isFlowEnabled(name),
Expand All @@ -4870,7 +4872,11 @@ export class AutomationEngine implements IAutomationService {
// Absent, not `undefined`-valued: a row that is bound, disabled
// or trigger-less has no reason to carry, and `reason: null` on
// the wire would read as "we looked and found nothing".
...(reason === undefined ? {} : { reason }),
// [#23842] `unboundCause` rides the SAME spread, so the pair is
// present together or absent together by construction.
...(unbound === undefined
? {}
: { reason: unbound.reason, unboundCause: unbound.cause }),
...(shadowing
? { armedFrom: shadowing.armed, shadowed: shadowing.shadowed }
: {}),
Expand All @@ -4886,6 +4892,11 @@ export class AutomationEngine implements IAutomationService {
* drift apart. `undefined` means this flow has no unbound reason to report:
* it is bound, it is disabled, or it declares no trigger at all.
*
* [#23842] Each branch answers its sentence AND its closed
* `FlowUnboundCause` together, so the status door can let a consumer
* classify a row without parsing the sentence — one value per branch,
* stamped where the branch is decided, never inferred from the text.
*
* The POLICY branch outranks both binding branches, and deliberately so.
* When package-authored scheduled work is off, neither of the other two
* reasons is true in any useful sense: the trigger was never called, so
Expand All @@ -4912,21 +4923,27 @@ export class AutomationEngine implements IAutomationService {
private describeUnboundReason(
name: string,
resolved: { triggerType: string } | undefined,
): string | undefined {
): { reason: string; cause: FlowUnboundCause } | undefined {
if (!resolved) return undefined; // manual / screen flow — nothing to bind
if (!this.isFlowEnabled(name)) return undefined;
if (this.boundFlowTriggers.has(name)) return undefined;
const policyReason = this.policyDisabledFlows.get(name);
if (policyReason !== undefined) return policyReason;
if (policyReason !== undefined) return { reason: policyReason, cause: 'deployment_policy' };
// [#20725, ADR-0126 §7.3] The arming gate declined it onto a disabled
// packaged subflow — read from the record, for the policy line's
// reason, and ahead of the trigger branches for the gate's: with the
// child off, the trigger would not arm it either.
const declined = this.subflowDeclinedFlows.get(name);
if (declined !== undefined) return declined;
if (declined !== undefined) return { reason: declined, cause: 'disabled_subflow' };
return this.triggers.has(resolved.triggerType)
? `trigger '${resolved.triggerType}' is registered but binding failed — see earlier warnings`
: `no '${resolved.triggerType}' trigger is registered — add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)`;
? {
reason: `trigger '${resolved.triggerType}' is registered but binding failed — see earlier warnings`,
cause: 'binding_failure',
}
: {
reason: `no '${resolved.triggerType}' trigger is registered — add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)`,
cause: 'missing_trigger',
};
}

/**
Expand All @@ -4946,9 +4963,9 @@ export class AutomationEngine implements IAutomationService {
// trigger) and the three-branch vocabulary both live in
// `describeUnboundReason` now, so this audit and the status door
// report the SAME sentence for the same flow by construction.
const reason = this.describeUnboundReason(name, resolved);
if (reason === undefined || !resolved) continue;
audit.push({ flowName: name, triggerType: resolved.triggerType, reason });
const unbound = this.describeUnboundReason(name, resolved);
if (unbound === undefined || !resolved) continue;
audit.push({ flowName: name, triggerType: resolved.triggerType, reason: unbound.reason });
}
return audit;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ describe('ADR-0126 §7.3 at REGISTRATION — the arming gate declines a packaged
expect(row).toMatchObject({ enabled: true, bound: false });
expect(row?.reason).toContain("'shared_step'");
expect(row?.reason).toContain('ADR-0126 §7.3');
// [#23842] The classifier rides beside the sentence: an admin remedy
// (enable the subflow), ⛔ neither a policy nor a binding defect.
expect(row?.unboundCause).toBe('disabled_subflow');
const declined = declineWarnings(logger, 'vendor_process');
expect(declined).toHaveLength(1);
expect(declined[0]).toContain("'shared_step' (switched off in the activation ledger)");
Expand Down Expand Up @@ -230,6 +233,7 @@ describe('ADR-0126 §7.3 at REGISTRATION — the arming gate declines a packaged

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

Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/contracts.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,10 @@
"ExecuteUpgradeInput (interface)",
"ExplainAccessRequest (interface)",
"FILE_ATTACHMENT_SERVICE (const)",
"FLOW_UNBOUND_CAUSES (const)",
"FinishReason (type)",
"FlowRuntimeState (interface)",
"FlowUnboundCause (type)",
"GenerateDraftOpts (interface)",
"GenerateObjectOptions (interface)",
"GrantShareInput (interface)",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/contracts.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,10 @@
"ExecuteUpgradeInput": "src/contracts/package-service.ts#ExecuteUpgradeInput (interface)",
"ExplainAccessRequest": "src/contracts/security-service.ts#ExplainAccessRequest (interface)",
"FILE_ATTACHMENT_SERVICE": "src/contracts/storage-service.ts#FILE_ATTACHMENT_SERVICE (const)",
"FLOW_UNBOUND_CAUSES": "src/contracts/automation-service.ts#FLOW_UNBOUND_CAUSES (const)",
"FinishReason": "node_modules/ai/dist/index.d.ts#FinishReason (type)",
"FlowRuntimeState": "src/contracts/automation-service.ts#FlowRuntimeState (interface)",
"FlowUnboundCause": "src/contracts/automation-service.ts#FlowUnboundCause (type)",
"GenerateDraftOpts": "src/contracts/external-datasource-service.ts#GenerateDraftOpts (interface)",
"GenerateObjectOptions": "src/contracts/ai-service.ts#GenerateObjectOptions (interface)",
"GrantShareInput": "src/contracts/sharing-service.ts#GrantShareInput (interface)",
Expand Down
38 changes: 37 additions & 1 deletion packages/spec/src/contracts/automation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

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

describe('Automation Service Contract', () => {
it('should allow a minimal IAutomationService implementation with required methods', () => {
Expand Down Expand Up @@ -457,4 +470,27 @@ describe('FlowRuntimeState — carries the reason a flow is unbound', () => {
expect(doc).toContain('RECORDED refusal');
expect(doc).toContain('getTriggerBindingAudit()');
});

it('[#23842] classifies through a closed tuple beside the sentence, and the sentence stays render-only', () => {
// One machine value per cause, snake_case, no duplicates, no catch-all.
expect(new Set(FLOW_UNBOUND_CAUSES).size).toBe(FLOW_UNBOUND_CAUSES.length);
for (const cause of FLOW_UNBOUND_CAUSES) expect(cause).toMatch(/^[a-z]+(_[a-z]+)*$/);
expect(FLOW_UNBOUND_CAUSES).not.toContain('other');
const policyRow: FlowRuntimeState = {
name: 'daily_digest',
enabled: true,
bound: false,
triggerType: 'schedule',
reason: 'disabled by deployment policy',
unboundCause: 'deployment_policy',
};
expect(FLOW_UNBOUND_CAUSES).toContain(policyRow.unboundCause);

const source = readFileSync(fileURLToPath(new URL('./automation-service.ts', import.meta.url)), 'utf8');
const block = source.slice(source.indexOf('export interface FlowRuntimeState'));
const doc = block.slice(0, block.indexOf('\n}'));
// `reason` still forbids parsing, and now names where classification lives.
expect(doc).toContain('do not parse it');
expect(doc).toContain('{@link FlowRuntimeState.unboundCause}');
});
});
43 changes: 42 additions & 1 deletion packages/spec/src/contracts/automation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,8 @@ export interface FlowRuntimeState {
* Whether a trigger is actually wired to this flow. `false` for a flow with
* no declared trigger (manual / screen flows) AND for one whose declared
* trigger type has no registered trigger — `triggerType` distinguishes the
* two.
* two, and on an enabled, triggered row {@link FlowRuntimeState.unboundCause}
* names which unbound cause applies.
*/
bound: boolean;
/** Persisted deployment status, when the flow carries one. */
Expand Down Expand Up @@ -658,10 +659,50 @@ export interface FlowRuntimeState {
*
* A free-form sentence, deliberately — the shape the two surfaces that
* already carry this reason use. Consumers RENDER it; ⛔ do not parse it.
* A consumer that must CLASSIFY the row (style a policy row apart from a
* defect) reads {@link FlowRuntimeState.unboundCause}, which rides beside
* this sentence on exactly the same rows.
*/
reason?: string;
/**
* WHICH cause {@link FlowRuntimeState.reason} describes, as a closed
* machine value — one of {@link FLOW_UNBOUND_CAUSES}. Present exactly on
* the rows that carry `reason`, absent on every other row (bound,
* disabled, or declaring no trigger), so a consumer classifies without
* parsing the sentence and renders the sentence without branching on it.
*/
unboundCause?: FlowUnboundCause;
}

/**
* Every distinct cause the automation engine reports for an ENABLED,
* trigger-declaring flow that is `bound: false` — one value per cause the
* producer actually composes, each with its own owner and remedy, so a value
* is never a catch-all:
*
* - `deployment_policy` — package-authored scheduled work is switched off on
* this deployment (the `ScheduledWorkPolicy` refusal, read from the
* recorded refusal). Operator remedy: the deployment switch or the host.
* ⛔ Not a defect.
* - `disabled_subflow` — the arming gate declined the flow because a packaged
* subflow it calls is disabled (ADR-0126 §7.3). Admin remedy: enable the
* subflow, and the caller arms.
* - `missing_trigger` — no trigger of the declared type is registered on this
* kernel. Author remedy: depend on the trigger package
* (`requires: ['triggers']`).
* - `binding_failure` — the trigger is registered but binding the flow to it
* failed. Engineering remedy: the earlier boot warnings name the error.
*/
export const FLOW_UNBOUND_CAUSES = [
'deployment_policy',
'disabled_subflow',
'missing_trigger',
'binding_failure',
] as const;

/** Why an enabled, triggered flow is not armed — derived from {@link FLOW_UNBOUND_CAUSES}. */
export type FlowUnboundCause = (typeof FLOW_UNBOUND_CAUSES)[number];

/**
* One window of execution runs, plus the truncation fact the window alone
* cannot carry (#19543).
Expand Down
Loading