diff --git a/.changeset/18235-flow-runtime-state-reason.md b/.changeset/18235-flow-runtime-state-reason.md new file mode 100644 index 00000000000..914690d901d --- /dev/null +++ b/.changeset/18235-flow-runtime-state-reason.md @@ -0,0 +1,16 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-automation": minor +"@objectstack/types": patch +--- + +`FlowRuntimeState` now declares `reason` — the optional sentence saying WHY a flow is not armed — and the automation engine populates it, so `GET /automation/_status` can tell a policy-disabled flow apart from a broken binding (#18235). + +Ruling G item 6 on #17396 names three surfaces that must each carry a DISTINCT reason for a flow left unarmed because package-authored scheduled work is switched off, and must never read as "binding failed". Two of them shipped: `getTriggerBindingAudit()` and the CLI startup summary. The third — a console — could not be built: Studio's only status door answers `FlowRuntimeState` rows, and that shape had no field a reason could travel in, so on the wire a policy-disabled flow was `enabled: true, bound: false, triggerType: 'schedule'`, byte-identical to one whose trigger is missing. + +**Clause-②: yes (widening)** — one new key on an already-published payload, so the shape a consumer reads against grows. Nothing previously emitted is removed or renamed, and no producer is required to write it. + +- **Optional, and additive by measurement.** Every producer of these rows — the engine, and the test doubles in `packages/runtime`, `packages/cli` and `packages/qa/dogfood` — writes `{ name, enabled, bound }` at minimum; a required key would have broken all of them and would demand a reason from rows that have none. The key is absent (not `undefined`-valued) on any row that is bound, disabled, or declares no trigger. +- **One vocabulary, not a new one.** The sentence is the one `getTriggerBindingAudit()` already answers for the same flow: both doors now read a single private `describeUnboundReason()` on the engine, so Studio and the boot summary cannot drift. A free-form string, matching the two surfaces that already carry this reason; ⛔ consumers render it, they do not parse it. +- **Read from the RECORD, never re-derived.** The policy sentence comes from the engine's recorded refusal (`policyDisabledFlows`, cleared the moment a flow gets past the gate), never from a live `resolveScheduledWorkPolicy()` read at call time. `_status` is served on demand, arbitrarily long after the bind — re-deriving would report a binding failure for a trigger that was never called, the defect the implementing round of #17396 already caught once. +- **Wire, not rendering.** `SCHEDULED_WORK_DISABLED_REASON`'s docblock is corrected: Studio's door now carries the reason, while displaying it distinctly remains objectui#9217's card. Declared is not delivered, and reaching the wire is not being shown. The published prose carrying the same claim moves with it — `content/docs/automation/flows.mdx`'s callout said the status door "has no field to say why", which this change makes false; both carriers are corrected in one landing, and neither now claims a console *renders* it. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index b3af4bf8451..3c791cb9464 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -2114,10 +2114,13 @@ policy** — a distinct reason, never "binding failed". Nothing about the flow needs fixing; the deployment has not asked for it. `os doctor` prints the effective value. -Studio does **not** carry that reason today: `GET /automation/_status` reports -each flow as `bound` or not and has no field to say why, so a policy-disabled -flow is indistinguishable there from one whose trigger is missing. Read the -startup summary or the audit for the reason. +The status door carries that reason too: `GET /automation/_status` answers one +row per flow, and an unarmed flow's row holds the same sentence in its optional +`reason` field — read from what the engine RECORDED when it refused the bind, +never re-derived later, so a switch flipped since cannot turn it into a binding +failure. ⛔ Reaching the wire is not the same as being **rendered**: whether a +console shows it as a distinct state rather than as an error is that console's +own change. The startup summary and the audit carry the same sentence. ⛔ Platform-internal scheduled work — approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill — is **not** gated by diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index e60c37b98f9..51f8ccae1b0 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -734,6 +734,34 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => { status: 'active', triggerType: 'on_create', object: 'sales_lead', }); }); + + /** + * [#18235] Ruled item 6's third surface is this door. The engine records WHY + * a flow is unarmed; this route is the only way that sentence reaches a + * console, and it reaches it by passing the row through — so the pin is that + * `reason` is NOT dropped on the way out, beside a row that carries none. + */ + it('/automation/_status carries the unbound reason, and omits it where there is none', async () => { + const automation = { + listFlows: vi.fn(), + getFlow: vi.fn(), + getFlowRuntimeStates: vi.fn().mockReturnValue([ + { + name: 'daily_digest', enabled: true, bound: false, status: 'active', triggerType: 'schedule', + reason: 'disabled by deployment policy — package-authored scheduled work is off on this deployment', + }, + { name: 'nurture', enabled: true, bound: true, status: 'active', triggerType: 'on_create', object: 'sales_lead' }, + ]), + }; + const result = await makeDispatcher({ automation, auth }).dispatch('GET', '/automation/_status', undefined, {}, {} as any); + expect(result.response?.status).toBe(200); + const flows = result.response?.body?.data?.flows as Array>; + expect(flows?.[0]?.reason, 'the policy sentence must survive the door').toMatch(/deployment policy/); + expect(String(flows?.[0]?.reason)).not.toMatch(/binding failed/); + // ⭐ DARK control: the healthy row is unchanged — no key appears on a + // row the producer did not put one on. + expect(Object.keys(flows?.[1] ?? {})).not.toContain('reason'); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index 5bd5adad66b..00a3938c08b 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -3497,4 +3497,118 @@ describe('AutomationEngine - the deployment switch (#17396)', () => { engine.registerFlow('b', scheduleFlow('b')); expect(rec.started.map((s) => s.flowName)).toEqual(['b']); }); + + // ─── the THIRD surface: the status door Studio reads (#18235) ── + // + // Ruled item 6 names three surfaces. Two of them — the audit above and the + // CLI startup summary that prints it — can ask the engine a second + // question. Studio cannot: its only status door is `GET /automation/_status`, + // which passes `getFlowRuntimeStates()` rows through verbatim. Until the row + // carried a reason, a policy-disabled flow reached that door as + // `enabled: true, bound: false` — byte-identical to one whose trigger is + // missing, which is the reading ruled item 6 forbids. + it('OFF: the status row names the policy, and ⛔ NOT a binding failure', () => { + delete process.env[SCHEDULED_WORK_ENV]; + const engine = new AutomationEngine(createTestLogger()); + engine.registerTrigger(recordingTrigger('schedule').trigger); + engine.registerFlow('digest', scheduleFlow('digest')); + + const row = engine.getFlowRuntimeStates().find((s) => s.name === 'digest'); + expect(row, 'control: the flow is listed at all').toBeTruthy(); + expect(row).toMatchObject({ enabled: true, bound: false, triggerType: 'schedule' }); + expect(row?.reason).toBe(SCHEDULED_WORK_DISABLED_REASON); + expect(row?.reason).not.toMatch(/binding failed/); + expect(row?.reason).toContain(SCHEDULED_WORK_ENV); + }); + + it('the two doors answer the SAME sentence for the same flow — one computation, no drift', () => { + // ⭐ ANTI-DRIFT PIN. The whole point of the third surface is that it + // agrees with the first: an operator reading Studio and an operator + // reading the boot summary must be told the same thing. Pinned as an + // identity rather than as two copies of the expected text, so a future + // edit to either door's wording fails here instead of silently forking + // the vocabulary ruled item 6 requires to be one. + delete process.env[SCHEDULED_WORK_ENV]; + const engine = new AutomationEngine(createTestLogger()); + engine.registerTrigger(recordingTrigger('schedule').trigger); + engine.registerFlow('digest', scheduleFlow('digest')); + engine.registerFlow('missing_trigger', { + ...scheduleFlow('missing_trigger'), + type: 'autolaunched' as const, + nodes: [ + { id: 'start', type: 'start' as const, label: 'On Update', config: { objectName: 'task', triggerType: 'record-after-update' } }, + { id: 'end', type: 'end' as const, label: 'End' }, + ], + }); + + const audit = engine.getTriggerBindingAudit(); + const states = engine.getFlowRuntimeStates(); + expect(audit.map((a) => a.flowName).sort(), 'control: both reasons are exercised').toEqual(['digest', 'missing_trigger']); + for (const entry of audit) { + expect( + states.find((s) => s.name === entry.flowName)?.reason, + `the status door and the binding audit disagree about '${entry.flowName}'`, + ).toBe(entry.reason); + } + // 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); + }); + + it('the status row reports what HAPPENED, not what the environment says when it is read', () => { + // ⭐ REGRESSION PIN, the status door's half. `_status` is served on + // demand, arbitrarily long after the bind — a strictly worse case than + // the audit's two boot-time callers. Re-deriving the reason from a live + // `resolveScheduledWorkPolicy()` here would make an operator who has + // just set the switch (and not yet restarted) see *binding failed* for + // a trigger that was never called. + delete process.env[SCHEDULED_WORK_ENV]; + const engine = new AutomationEngine(createTestLogger()); + const rec = recordingTrigger('schedule'); + engine.registerTrigger(rec.trigger); + engine.registerFlow('digest', scheduleFlow('digest')); + expect(rec.started, 'control: the flow really was refused by policy').toHaveLength(0); + + process.env[SCHEDULED_WORK_ENV] = 'true'; + + expect(engine.getFlowRuntimeStates().find((s) => s.name === 'digest')?.reason) + .toBe(SCHEDULED_WORK_DISABLED_REASON); + }); + + it('ON: a genuine bind failure reads as one on the status row too', () => { + process.env[SCHEDULED_WORK_ENV] = 'true'; + const engine = new AutomationEngine(createTestLogger()); + engine.registerTrigger({ + type: 'schedule', + start() { throw new Error('the job service refused'); }, + stop() {}, + }); + engine.registerFlow('digest', scheduleFlow('digest')); + + const row = engine.getFlowRuntimeStates().find((s) => s.name === 'digest'); + expect(row?.reason).toMatch(/binding failed/); + expect(row?.reason).not.toBe(SCHEDULED_WORK_DISABLED_REASON); + }); + + it('⭐ DARK: a row with nothing to explain carries NO reason key at all', () => { + // The five members that were there before this card behave exactly as + // they did, and `reason` is absent — not `undefined`, not an empty + // string — on every row that is bound or disabled. A consumer that + // styles on `reason` must not light up for a healthy flow. + process.env[SCHEDULED_WORK_ENV] = 'true'; + const engine = new AutomationEngine(createTestLogger()); + engine.registerTrigger(recordingTrigger('schedule').trigger); + engine.registerTrigger(recordingTrigger('record_change').trigger); + engine.registerFlow('digest', scheduleFlow('digest')); // bound + engine.registerFlow('rc_obsolete', { ...recordChangeFlow('rc_obsolete'), status: 'obsolete' }); // disabled + + const states = engine.getFlowRuntimeStates(); + const bound = states.find((s) => s.name === 'digest'); + const disabled = states.find((s) => s.name === 'rc_obsolete'); + expect(bound).toMatchObject({ enabled: true, bound: true }); + expect(disabled).toMatchObject({ enabled: false, bound: false }); + expect(Object.keys(bound ?? {}), 'a bound flow explains nothing').not.toContain('reason'); + expect(Object.keys(disabled ?? {}), '`enabled: false` already says it').not.toContain('reason'); + expect(engine.getTriggerBindingAudit(), 'control: the sibling door is empty for the same reason').toHaveLength(0); + }); }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 8e77f85a301..ec75d9cf722 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -4083,6 +4083,12 @@ export class AutomationEngine implements IAutomationService { * registered trigger. `triggerType`/`object` expose the flow's declared * binding so hosts (CLI startup summary, kernel:bootstrapped audit) can say * WHY an unbound flow is unbound; `status` is the persisted deployment status. + * + * [#18235, ruling G item 6] `reason` carries that WHY on the row itself, for + * the one host that cannot ask a second question: Studio's only status door + * is `GET /automation/_status`, which passes these rows through verbatim and + * has no access to {@link getTriggerBindingAudit}. Same sentence, same + * eligibility, one computation — see {@link describeUnboundReason}. */ getFlowRuntimeStates(): Array<{ name: string; @@ -4091,6 +4097,7 @@ export class AutomationEngine implements IAutomationService { status?: string; triggerType?: string; object?: string; + reason?: string; armedFrom?: FlowContender; shadowed?: FlowContender[]; }> { @@ -4100,6 +4107,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); return { name, enabled: this.isFlowEnabled(name), @@ -4107,6 +4115,10 @@ export class AutomationEngine implements IAutomationService { status: (this.flows.get(name) as { status?: string } | undefined)?.status, triggerType: resolved?.triggerType, object: resolved?.binding.object, + // 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 }), ...(shadowing ? { armedFrom: shadowing.armed, shadowed: shadowing.shadowed } : {}), @@ -4114,6 +4126,47 @@ export class AutomationEngine implements IAutomationService { }); } + /** + * [#17396 ruled item 6, #18235] The ONE place that decides why a registered + * flow is not armed — both the status door ({@link getFlowRuntimeStates}) + * and the boot-time audit ({@link getTriggerBindingAudit}) read it, so the + * sentence an operator sees in Studio and the one the CLI prints cannot + * 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. + * + * 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 + * nothing "failed", and registering the missing trigger would change + * nothing, so "add requires: ['triggers']" is a remedy that does not work. + * ⛔ Never reported as "binding failed" — a binding failure is a defect with + * an engineering remedy, while this is a deployment policy with an operator + * remedy, and the two send the reader to different places. + * + * ⛔ Read from the RECORD, never re-derived from the environment here. + * Re-deriving was the first spelling and it was measured wrong: both + * callers run long after the bind, so an environment that moved in + * between — an operator setting the switch, a test restoring it — makes + * this report *binding failed* for a flow whose trigger was never called. + * The record says what HAPPENED; `activateFlowTrigger` clears it the moment + * the flow gets past the gate. + * + * @param resolved the caller's already-resolved binding, so neither door + * pays for a second {@link resolveTriggerBinding} on the same row. + */ + private describeUnboundReason( + name: string, + resolved: { triggerType: string } | undefined, + ): string | undefined { + if (!resolved) return undefined; // manual / screen flow — nothing to bind + if (!this.isFlowEnabled(name)) return undefined; + if (this.boundFlowTriggers.has(name)) return undefined; + if (this.policyDisabledFlows.has(name)) return SCHEDULED_WORK_DISABLED_REASON; + 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-*)`; + } + /** * Silent-miss audit (2026-07-17 third-party eval): every ENABLED flow that * declares an auto-launch trigger but is not bound to one, with the reason. @@ -4126,33 +4179,13 @@ export class AutomationEngine implements IAutomationService { getTriggerBindingAudit(): Array<{ flowName: string; triggerType: string; reason: string }> { const audit: Array<{ flowName: string; triggerType: string; reason: string }> = []; for (const name of this.flows.keys()) { - if (!this.isFlowEnabled(name)) continue; - if (this.boundFlowTriggers.has(name)) continue; const resolved = this.resolveTriggerBinding(name); - if (!resolved) continue; // manual / screen flow — nothing to bind - // [#17396] 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 nothing "failed", and registering - // the missing trigger would change nothing, so "add - // requires: ['triggers']" is a remedy that does not work. ⛔ Never - // reported as "binding failed" — a binding failure is a defect with - // an engineering remedy, while this is a deployment policy with an - // operator remedy, and the two send the reader to different places. - // - // ⛔ Read from the RECORD, never re-derived from the environment - // here. Re-deriving was the first spelling and it was measured - // wrong: the audit is read long after the bind, so an environment - // that moved in between — an operator setting the switch, a test - // restoring it — makes this method report *binding failed* for a - // flow whose trigger was never called. The record says what - // HAPPENED; `activateFlowTrigger` clears it the moment the flow - // gets past the gate. - const reason = this.policyDisabledFlows.has(name) - ? SCHEDULED_WORK_DISABLED_REASON - : 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-*)`; + // [#18235] The eligibility rules (enabled, unbound, declares a + // 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 }); } return audit; diff --git a/packages/spec/src/contracts/automation-service.test.ts b/packages/spec/src/contracts/automation-service.test.ts index 8c55ad016d8..c95073a7458 100644 --- a/packages/spec/src/contracts/automation-service.test.ts +++ b/packages/spec/src/contracts/automation-service.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; -import type { IAutomationService, AutomationResult } from './automation-service'; +import type { IAutomationService, AutomationResult, FlowRuntimeState } from './automation-service'; import type { FlowParsed } from '../automation/flow.zod'; import { FlowSchema } from '../automation/flow.zod'; import type { ExecutionLog } from '../automation/execution.zod'; @@ -32,6 +32,24 @@ export type RestoreAnswersTheNarrowResult = Assert< Eq>, { restored: boolean; runId: string; refusal?: string; reason: string }> >; +/** + * [#18235, #17396 ruled item 6] The status door's WHY. Pinned as an identity so + * neither half can move quietly: `string | undefined` fails if the key is + * dropped or renamed (⇒ Studio loses the third surface again), and equally if + * it is ever narrowed to a closed union — the two surfaces that already carry + * this reason answer a free-form sentence, and the objectui card that renders + * it accepts a string precisely because the platform ships one. + */ +export type FlowRuntimeStateCarriesAnOptionalReason = Assert>; +/** + * LIT CONTROL for the five members that were there before: `reason` is + * ADDITIVE. A producer that writes only the three required members still + * satisfies the contract, so no existing producer is broken by this key. + */ +export type FlowRuntimeStateReasonIsOptional = Assert< + { name: string; enabled: boolean; bound: boolean } extends FlowRuntimeState ? true : false +>; + describe('Automation Service Contract', () => { it('should allow a minimal IAutomationService implementation with required methods', () => { const service: IAutomationService = { @@ -394,3 +412,49 @@ describe('Automation Service Contract', () => { }); }); }); + +/** + * [#18235] `FlowRuntimeState.reason` — the platform half of ruled item 6's + * third surface. The contract is the whole of this card: `GET /automation/_status` + * passes these rows to Studio verbatim, so a shape with no reason field made a + * policy-disabled flow indistinguishable from a broken binding on the wire. + */ +describe('FlowRuntimeState — the unbound reason (#18235)', () => { + it('a row that explains itself and one that does not both satisfy the contract', () => { + // PROBE: the reason-carrying row, in the shape the engine emits for a flow + // the deployment switch refused. + const policyDisabled: FlowRuntimeState = { + name: 'daily_digest', + enabled: true, + bound: false, + status: 'active', + triggerType: 'schedule', + reason: 'disabled by deployment policy — package-authored scheduled work is off on this deployment', + }; + // LIT CONTROL: the five pre-existing members, untouched. A minimal row + // still type-checks, which is what makes the new key additive rather than + // a break for every producer. + const minimal: FlowRuntimeState = { name: 'manual_only', enabled: true, bound: false }; + + expect(policyDisabled.reason).toBeDefined(); + expect(policyDisabled.reason).not.toMatch(/binding failed/); + // ⭐ DARK: absent, not `undefined`-valued — a consumer that styles on the + // key's presence must see nothing here. + expect(Object.keys(minimal)).not.toContain('reason'); + expect(minimal.reason).toBeUndefined(); + }); + + it('the contract documents where the sentence comes from, so a second vocabulary is not invented', () => { + 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}')); + // The ruling's distinction, stated on the field a consumer reads. + expect(doc).toContain('SCHEDULED_WORK_DISABLED_REASON'); + // `[\s*]+` for the docblock's own line wrapping, the form the pins above use. + expect(doc).toMatch(/never reads as "binding[\s*]+failed"/); + // Where the sentence is read FROM — the recorded refusal, never a live + // environment read (the defect the producing round already caught once). + expect(doc).toContain('RECORDED refusal'); + expect(doc).toContain('getTriggerBindingAudit()'); + }); +}); diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 1723f567cbc..8d9231d401b 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -514,6 +514,34 @@ export interface FlowRuntimeState { triggerType?: string; /** Object the trigger binds to, for object-bound trigger types. */ object?: string; + /** + * WHY this flow is not armed, in one sentence — present only on a row that + * is `enabled`, `bound: false` and declares a trigger, absent on every + * other row. `bound: false` alone cannot be read as a defect: the states + * behind it have different owners and different remedies, and this is the + * field that tells them apart on the wire. + * + * [#17396 ruling G item 6] A flow left unarmed because package-authored + * scheduled work is switched OFF on this deployment carries a DISTINCT + * sentence — `SCHEDULED_WORK_DISABLED_REASON` (`@objectstack/types`), + * which names the switch and its remedy — and ⛔ never reads as "binding + * failed": a binding failure is a defect with an engineering remedy, while + * this is a deployment policy with an operator one, and the two send the + * reader to different places. Before this field existed the two reached + * Studio as the same `enabled: true, bound: false` row (#18235). + * + * ⛔ Not a new vocabulary: the producer answers the same sentence the + * engine's `getTriggerBindingAudit()` reports for the same flow — one + * computation, two doors — so the status door and the boot-time audit + * cannot drift. The policy sentence is read from the RECORDED refusal, ⛔ + * never re-derived from a live environment read: the status door is called + * long after the bind, so an environment that moved in between would + * otherwise report a binding failure for a trigger that was never called. + * + * A free-form sentence, deliberately — the shape the two surfaces that + * already carry this reason use. Consumers RENDER it; ⛔ do not parse it. + */ + reason?: string; } export interface IAutomationService { diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index d9ff5a554ae..7fc343fc7a7 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -289,16 +289,17 @@ export function resolveScheduledWorkPolicy(): ScheduledWorkPolicy { /** * The one sentence a surface prints when package-authored scheduled work is - * OFF — so the bind refusal, the engine's binding audit and the CLI startup - * summary cannot drift about WHY a flow is not armed. - * - * ⚠️ Those are the surfaces that carry it, and Studio is NOT one of them. Its - * only status door is `GET /automation/_status`, whose rows are - * `FlowRuntimeState` (`@objectstack/spec` `contracts/automation-service.ts`) — - * a shape with no reason field at all — so a policy-disabled flow reaches - * Studio as `bound: false` and nothing more. ⛔ Do not write that Studio - * reports this reason until a reason reaches that wire shape: declared is not - * delivered. + * OFF — so the bind refusal, the engine's binding audit, the CLI startup + * summary and the flow status door cannot drift about WHY a flow is not armed. + * + * ⚠️ [#18235] Studio's own door carries it now: `GET /automation/_status` + * answers `FlowRuntimeState` rows (`@objectstack/spec` + * `contracts/automation-service.ts`), and their optional `reason` holds this + * sentence verbatim for a policy-disabled flow — read from the engine's + * RECORDED refusal, the same computation the binding audit reads. What is on + * the wire is the reason; what a console DISPLAYS is its own card + * (objectui#9217, open). ⛔ So do not write that Studio *renders* this + * distinctly until that lands — reaching the wire is not being shown. * * ⛔ It must never read as "binding failed". A binding failure is a defect with * an engineering remedy; this is a deployment POLICY with an operator remedy,