Skip to content

Commit 748b240

Browse files
feat(types,automation): a host's per-kernel scheduled-work OFF reports its own reason (#21270)
Fixes #21110 Clause-②: yes (widening) **Cross-lane surfaces, named before the change list:** - `packages/types/src/env.ts` (`@objectstack/types`, a `domain:cli` file). `ScheduledWorkPolicy` gains one optional field and the package gains one export. The claim (comment 5942544148) declares it as its cross-lane surface, per triage's routing (5927226845). - `packages/spec/src/contracts/automation-service.ts` (a `domain:spec` file), **TSDoc only**: the `FlowRuntimeState.reason` docblock. No key, type, export or `.describe()` changes. The claim revision (comment 5943184720) adds it in patch round 1, because this PR made that published sentence false for a host-injected OFF. ## What was wrong, measured on `main` at `434c6c7ca` This was a real boot: `@objectstack/verify` `bootStack` with a real `AutomationServicePlugin` given `scheduledWorkPolicy: { enabled: false, ... }`, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED='true'`, and one `schedule` flow plus one `time_relative` flow in the app config. All three surfaces named the variable as the cause, even though it was set: - `GET /automation/_status` (HTTP 200): both rows had `bound: false` and a `reason` equal to `SCHEDULED_WORK_DISABLED_REASON` ("... (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy) ... set OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true ..."). - `getTriggerBindingAudit()`: the same sentence for both flows. - The bind log: `Flow 'swr_nightly' is not armed on trigger 'schedule' — disabled by deployment policy — ... is unset or not truthy ...`. The `time_relative` flow's line was the same. So the premise holds. ## The change Triage's direction B: one optional host reason and one answer, with no second copy of the sentence. - **`@objectstack/types`** (`env.ts`) - `ScheduledWorkPolicy.hostDisabledReason?: string` is the host's own sentence for why scheduled work is off on this kernel. It is read only when `enabled` is `false`. - `scheduledWorkDisabledReason(policy)` is the one answer. It returns `policy.hostDisabledReason` when present, and otherwise `SCHEDULED_WORK_DISABLED_REASON`, byte for byte. - `resolveScheduledWorkPolicy()` never sets the field, so the environment switch keeps exactly one sentence. - The docblocks on the field, the function, the constant and the resolver say so. They also say why the name carries "host": the field is not a remedy for the deployment path and not a way to reword it. - **`@objectstack/service-automation`** (`engine.ts`) - `activateFlowTrigger` reads the policy once per bind, and still only for a time-triggered kind. - It records `scheduledWorkDisabledReason` of the reading that refused. `policyDisabledFlows` changes from a set of flow names to a map from flow name to sentence. - The bind log prints that sentence. - `describeUnboundReason` (the binding audit and the `/_status` row) returns the recorded sentence. It is still read from the record and never re-derived. - The engine no longer imports the constant at all. - The log line repeats only when the recorded sentence changes. - **`@objectstack/trigger-schedule`** - `refuseScheduledWorkDisabled` takes the policy its caller's gate refused on and reports `scheduledWorkDisabledReason(policy)`. It is not exported from the package index. - Both callers pass the reading their gate refused on. They are `schedule-trigger.ts` and `time-relative-trigger.ts`. The second file is a one-line call-site edit inside the claim's cleared `trigger-schedule/src/`. It is named here because the claim's file list names only `schedule-trigger.ts`. - The README's per-kernel paragraph no longer says a policy-unarmed flow always gets the deployment sentence. - **`@objectstack/spec`** (`contracts/automation-service.ts`, TSDoc only): the `FlowRuntimeState.reason` docblock no longer says a policy-disabled flow always carries `SCHEDULED_WORK_DISABLED_REASON`. It now says such a flow carries the policy's reason, `scheduledWorkDisabledReason(policy)` from `@objectstack/types`: the host's `hostDisabledReason` when a host-injected per-kernel `ScheduledWorkPolicy` carries one, else `SCHEDULED_WORK_DISABLED_REASON`, which names the deployment switch and its remedy. The rest of that docblock is unchanged, including the "never reads as binding failed" rule, the "not a new vocabulary" and recorded-refusal rule, and the #17396 / #18235 history. `pnpm --filter @objectstack/spec check:generated` reports all 15 generated artifacts up to date, so nothing is regenerated. The spec gets no changeset entry, because nothing it publishes changes beyond a comment. - **Changeset**: `minor` for all three packages (an optional field and a new export on `@objectstack/types`, and the engine and triggers now honour that key). The changeset carries `Clause-②: yes (widening)`. ## After the change, measured at `48f366522` through the same real boot over HTTP | case | `/_status` reason (both rows) | audit (both flows) | bind log | |:--|:--|:--|:--| | host policy `enabled: false` + `hostDisabledReason`, variable `'true'` | the host sentence | the host sentence | `... is not armed on trigger 'schedule' — ` + the host sentence | | host policy `enabled: false`, no reason, variable `'true'` | `SCHEDULED_WORK_DISABLED_REASON` | same | same | | control: no host policy, variable unset | `SCHEDULED_WORK_DISABLED_REASON` | same | same | That boot used a scratch dogfood file, which is not committed. See Acceptance notes for why. ## Pins (committed) - `packages/types/src/env.test.ts`: - a host reason is answered verbatim; - a host policy with no reason answers `SCHEDULED_WORK_DISABLED_REASON`; - the resolver's reading never carries the key (checked by key presence), so an unset switch reports the deployment sentence. - `packages/services/service-automation/src/per-kernel-scheduled-work-policy.test.ts`, against the engine source: - host OFF with a reason, under the deployment ON and OFF: the bind log, the audit and the `getFlowRuntimeStates()` row (the row `/_status` serves verbatim) all carry the host reason, and none contains the variable name; - host OFF with no reason: the deployment sentence on all three; - the control (no policy, variable unset): the deployment sentence on all three; - a resolver that changes after the bind still reports the refusing reading's sentence; - booted through `AutomationServicePlugin`. - `packages/triggers/trigger-schedule/src/per-kernel-scheduled-work-policy.test.ts`: - a LiteKernel acceptance case: a host OFF with a reason under the deployment ON, using the real `AutomationServicePlugin`, `ScheduleTriggerPlugin` and `TimeRelativeTriggerPlugin`. Nothing is scheduled, and the audit and both `/_status` rows carry the host reason; - `ScheduleTrigger` and `TimeRelativeTrigger`, driven directly: the refusal's message and its `info` line carry the host reason and do not name the variable; - a host OFF with no reason keeps the deployment sentence. - The pins assert which sentence is reported (identity with the host string or with the exported constant, and absence of the variable name). They do not assert the refusal's own framing words. ## Ablations The fix was committed first. Each mutation went through `scripts/ablation-replace.mjs`, which checks that the anchor hit and the blob changed, and then that the blob equals HEAD and `git diff HEAD` is empty after the restore. Each site was put back on the hard-coded deployment sentence: | leg | site | suite | result | |:--|:--|:--|:--| | A1 | engine bind log | service-automation pins | 2 failed / 10 passed: both host-reason cases | | A2 | engine `describeUnboundReason` (audit + `/_status`) | service-automation pins | 4 failed / 8 passed: both host-reason cases, the record pin, the plugin-boot pin | | A3 | the trigger refusal | trigger-schedule pins | 2 failed / 8 passed: the `ScheduleTrigger` and `TimeRelativeTrigger` host-reason cases | | A4 | `scheduledWorkDisabledReason` returns `''` without a host reason (ablates the default-kept control) | types / service-automation / trigger-schedule | 2 failed / 53 passed; 6 failed / 6 passed; 6 failed / 4 passed | No `dist/` was involved in A1–A3, because each of those suites reads the mutated file from source. In A4, the trigger-schedule acceptance cases went red too, because that package aliases `@objectstack/types` to source. ## Verification Gates were re-derived and re-run at `cbdd42efe`, the final commit. The package suites and the typecheck ran at `f80ddaaf2`. Round 1 moved no file in `types`, `service-automation` or `trigger-schedule`; it changed one spec comment. - Build: `pnpm turbo run build --filter=@objectstack/types --filter=@objectstack/service-automation --filter=@objectstack/trigger-schedule`. The new symbols were confirmed in `types/dist` and `service-automation/dist`. - Tests: - `pnpm --filter @objectstack/types test`: 22 files, 688 passed. - `pnpm --filter @objectstack/trigger-schedule test`: 8 files, 174 passed. - `pnpm --filter @objectstack/service-automation exec vitest run`: 162 files, 2027 passed. - Typecheck: `typecheck` for the three packages exits 0, including service-automation's `check:test-typecheck`. - Gates at `cbdd42efe`: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 85 commands. That is the 62 from `f80ddaaf2` plus 23 the spec path adds, among them `check:api-surface`, `check:docs`, `check:authorable-surface`, `check:spec-docblock-symbol-anchors` and `check:merge-driver`. All 85 were run against a full workspace build (`turbo run build --filter=!@objectstack/docs`, 72 tasks), and all exited 0. `--ran` reconciliation: "85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN". At `f80ddaaf2` the 62 also all exited 0. There, `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3) for 8 packages with no `dist/`, and passed once those 8 were built. - Spec, round 1: `src/contracts/automation-service.test.ts` passed 17 of 17. Its doc pin still finds `SCHEDULED_WORK_DISABLED_REASON`, "never reads as binding failed", "RECORDED refusal" and `getTriggerBindingAudit()` in the docblock. - Also run, because the engine's recorded refusal changed shape: `check:startup-registry-verdict` and `check:durability-log-level`, both exit 0. - Lint, narrowed: `eslint --no-inline-config --format json` over the 7 changed `.ts` files gave 7 files, 0 errors, 0 warnings, none ignored. `--print-config` shows no `parserOptions.project` or `projectService` on any of them. Lint is not type-aware, so this diff cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is left to CI. - `origin/main` has moved to `ee42f00e3` since the base. Those commits touch none of `packages/types`, `service-automation`, `trigger-schedule`, the spec contract or the runtime `_status` handler (empty diffstat), so this branch is not merged with it. ## Docs - `packages/triggers/trigger-schedule/README.md`, per-kernel paragraph. Old: "A flow the policy leaves unarmed is reported with the same policy reason as a deployment-disabled one." New: it is reported the way a deployment-disabled one is, and the reason is the policy's `hostDisabledReason` when the host sets one, otherwise the deployment's sentence. - `content/docs/**` (outside `releases/`) and `skills/**`: zero hits for `ScheduledWorkPolicy`, `scheduledWorkPolicy`, `SCHEDULED_WORK_DISABLED_REASON` or `hostDisabledReason`. The positive control is the same pattern's hit on the trigger-schedule README. Eight lines name `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` (in `automation/flows.mdx`, `automation/jobs.mdx`, `deployment/environment-variables.mdx`, `deployment/production-readiness.mdx`, `deployment/tenancy-modes.mdx` and `references/automation/schedule-organization.mdx`). Every one describes the environment-switch path, which this PR leaves byte-identical. None becomes false, so none is edited. ## Acceptance notes - **The spec contract's `FlowRuntimeState.reason` TSDoc is fixed in this PR (patch round 1).** It said a policy-disabled flow carries `SCHEDULED_WORK_DISABLED_REASON`, and this PR made that published sentence false for a host-injected OFF with a `hostDisabledReason`. It now names the policy's reason, `scheduledWorkDisabledReason(policy)`. `ScheduledWorkPolicy` has no zod or spec mirror (zero hits in `packages/spec/src`), so no other spec text moves, and no generated artifact changes. - **Other readers of `SCHEDULED_WORK_DISABLED_REASON`, left as they are.** `packages/runtime/src/app-plugin.ts` (the declarative `defineJob` gate) quotes the constant, but it refuses only when the zero-argument `resolveScheduledWorkEnabled()` is off. There the deployment sentence is the true cause. Reach: no wrong sentence is reachable through it, because a per-kernel host OFF never reaches that loop. That is the separate open question 1 of the earlier per-kernel card (the declarative-job gate ignores a host policy), and it is not this card's scope. The other readers are tests on the environment path (`engine.test.ts` and the dogfood `schedule-sweep-organization-scope` suite), and both remain correct. - **No door-level dogfood pin is committed.** `@objectstack/dogfood` does not depend on `@objectstack/service-automation`, and `bootStack`'s `automation` option takes no policy. A committed door pin would therefore need a new dependency (`package.json` and the lockfile) or a new `bootStack` option in `packages/verify`, and both are outside the claimed file surface. The `/_status` handler (`packages/runtime/src/domains/automation.ts`, the `_status` branch) returns `getFlowRuntimeStates()` verbatim, and that is the row the committed pins read. The HTTP door itself was measured once before and once after, as above. - `hostDisabledReason` is not validated as non-empty. The docblock asks for a whole, non-empty sentence. Falling back to the deployment sentence for a blank value would bring back the wrong-cause defect without anyone noticing, and refusing it would turn a reporting slip into a bind failure. Neither was added. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c7396f1 commit 748b240

10 files changed

Lines changed: 450 additions & 29 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/types': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/trigger-schedule': minor
5+
---
6+
7+
feat(types,automation): a host's per-kernel scheduled-work OFF reports the host's own reason (#21110)
8+
9+
Clause-②: yes (widening)
10+
11+
`ScheduledWorkPolicy` (`@objectstack/types`) gains an optional
12+
`hostDisabledReason`: the host's own sentence for why scheduled work is off on
13+
this kernel, such as a plan that does not include scheduled flows. A new
14+
export, `scheduledWorkDisabledReason(policy)`, gives the one answer for why
15+
scheduled work is not armed under a policy. It returns the host's reason when
16+
the policy carries one, and `SCHEDULED_WORK_DISABLED_REASON` otherwise.
17+
18+
Every refusal site now reports that answer, read from the same policy reading
19+
that refused:
20+
21+
- the automation engine's bind log;
22+
- the reason it records for `getTriggerBindingAudit()` and for the
23+
`FlowRuntimeState.reason` that `GET /automation/_status` serves;
24+
- the refusal of `ScheduleTrigger` and `TimeRelativeTrigger` when a host drives
25+
them directly.
26+
27+
Before this, a kernel that a host turned off through `scheduledWorkPolicy`
28+
was reported with the deployment sentence. That sentence tells the reader to
29+
set `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true`, even on a process where the
30+
variable is already set, and to a tenant who cannot set it.
31+
32+
Nothing changes without the new field. A policy with no `hostDisabledReason`,
33+
and the zero-argument deployment resolver `resolveScheduledWorkPolicy()`, which
34+
never sets it, report `SCHEDULED_WORK_DISABLED_REASON` byte for byte. The field
35+
is read only when `enabled` is `false`.
36+
37+
To use it, a host that turns one kernel off for its own reason sets
38+
`hostDisabledReason` on the `enabled: false` policy it already hands to that
39+
kernel's `AutomationServicePlugin`, `ScheduleTriggerPlugin` and
40+
`TimeRelativeTriggerPlugin`. Give the same policy to all three, as before, and
41+
make the reason a whole sentence that names the cause and the remedy. It is
42+
shown verbatim.

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

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlo
2727
import { resolveFlowTriggerKind, resolveScheduleOrganization } from '@objectstack/spec/automation';
2828
import {
2929
resolveScheduledWorkPolicy,
30-
SCHEDULED_WORK_DISABLED_REASON,
30+
scheduledWorkDisabledReason,
3131
type ScheduledWorkPolicy,
3232
} from '@objectstack/types';
3333
import { predicateSlotRefusal, resolveFlowNodeExpressions, structuralConditionRefusal } from '@objectstack/spec/automation';
@@ -875,8 +875,11 @@ export interface AutomationEngineOptions {
875875
* (one scheduled work OFF, its sibling ON): the per-kernel answer has
876876
* nowhere else to live, because the deployment resolver reads one
877877
* process-wide environment. A time-triggered flow this policy leaves
878-
* unarmed is reported exactly as a deployment-disabled one —
879-
* `SCHEDULED_WORK_DISABLED_REASON` on the binding audit and the status row.
878+
* unarmed is reported through the same branch as a deployment-disabled one
879+
* — on the bind log, the binding audit and the status row — with the
880+
* sentence `scheduledWorkDisabledReason(policy)` answers [#21110]: the
881+
* policy's `hostDisabledReason` when the host gave one, else
882+
* `SCHEDULED_WORK_DISABLED_REASON`, which names the deployment switch.
880883
*
881884
* ⚠️ Hand the SAME policy to `ScheduleTriggerPlugin` and
882885
* `TimeRelativeTriggerPlugin` of the same kernel: each trigger keeps its own
@@ -2451,8 +2454,17 @@ export class AutomationEngine implements IAutomationService {
24512454
* the gate, and {@link unregisterFlow} drops it with the flow. A later
24522455
* registration under a switched-on deployment clears it by the ordinary
24532456
* path.
2457+
*
2458+
* ## Why it records the SENTENCE, not just the flow
2459+
*
2460+
* [#21110] The reason depends on the policy that refused: a host-injected
2461+
* per-kernel policy may carry its own `hostDisabledReason`, and only the
2462+
* deployment's answer names `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`. So the
2463+
* value is `scheduledWorkDisabledReason(policy)` of the reading that
2464+
* REFUSED, kept for the same reason the key is: a resolver re-asked at read
2465+
* time may answer something else by then.
24542466
*/
2455-
private readonly policyDisabledFlows = new Set<string>();
2467+
private readonly policyDisabledFlows = new Map<string, string>();
24562468
/**
24572469
* [#20725, ADR-0126 §7.3] Packaged callers {@link activateFlowTrigger}
24582470
* declined to arm because a packaged subflow they call is disabled — each
@@ -3684,17 +3696,27 @@ export class AutomationEngine implements IAutomationService {
36843696
// between the two: see its own docblock for why the audit must read
36853697
// what happened rather than re-derive it from an environment that may
36863698
// have moved since.
3687-
if (isTimeTriggeredKind(resolved.triggerType) && !this.readScheduledWorkPolicy().enabled) {
3688-
if (!this.policyDisabledFlows.has(flowName)) {
3689-
this.policyDisabledFlows.add(flowName);
3690-
// Said once per flow while it stays refused, at `info`, for the
3691-
// reason the trigger's own refusal records: this is the DEFAULT
3692-
// state of every deployment and the deployment declared it, so
3693-
// nothing is wrong and nothing looks normal-but-broken. The
3694-
// structured channel is the audit below, which the
3695-
// `kernel:bootstrapped` hook and the CLI startup summary read.
3699+
//
3700+
// The policy is read only for a time-triggered kind, as before: a
3701+
// record-change or api flow never asks it.
3702+
const policy = isTimeTriggeredKind(resolved.triggerType) ? this.readScheduledWorkPolicy() : undefined;
3703+
if (policy !== undefined && !policy.enabled) {
3704+
// [#21110] The sentence comes from the SAME reading that refused —
3705+
// the host's own reason when its per-kernel policy carries one, else
3706+
// the deployment sentence — and the bind log, the audit and the
3707+
// `/_status` row all read this one recorded value.
3708+
const reason = scheduledWorkDisabledReason(policy);
3709+
if (this.policyDisabledFlows.get(flowName) !== reason) {
3710+
this.policyDisabledFlows.set(flowName, reason);
3711+
// Said once per flow while it stays refused for the same
3712+
// reason, at `info`, for the reason the trigger's own refusal
3713+
// records: this is the DEFAULT state of every deployment and the
3714+
// deployment declared it, so nothing is wrong and nothing looks
3715+
// normal-but-broken. The structured channel is the audit below,
3716+
// which the `kernel:bootstrapped` hook and the CLI startup
3717+
// summary read.
36963718
this.logger.info(
3697-
`Flow '${flowName}' is not armed on trigger '${resolved.triggerType}' — ${SCHEDULED_WORK_DISABLED_REASON}`,
3719+
`Flow '${flowName}' is not armed on trigger '${resolved.triggerType}' — ${reason}`,
36983720
);
36993721
}
37003722
return;
@@ -4538,7 +4560,10 @@ export class AutomationEngine implements IAutomationService {
45384560
* between — an operator setting the switch, a test restoring it — makes
45394561
* this report *binding failed* for a flow whose trigger was never called.
45404562
* The record says what HAPPENED; `activateFlowTrigger` clears it the moment
4541-
* the flow gets past the gate.
4563+
* the flow gets past the gate. [#21110] And it holds the SENTENCE, not just
4564+
* the fact: `scheduledWorkDisabledReason` of the policy reading that
4565+
* refused, so a host-injected OFF reports the host's reason here and the
4566+
* deployment switch's sentence is reported only where that switch decided.
45424567
*
45434568
* @param resolved the caller's already-resolved binding, so neither door
45444569
* pays for a second {@link resolveTriggerBinding} on the same row.
@@ -4550,7 +4575,8 @@ export class AutomationEngine implements IAutomationService {
45504575
if (!resolved) return undefined; // manual / screen flow — nothing to bind
45514576
if (!this.isFlowEnabled(name)) return undefined;
45524577
if (this.boundFlowTriggers.has(name)) return undefined;
4553-
if (this.policyDisabledFlows.has(name)) return SCHEDULED_WORK_DISABLED_REASON;
4578+
const policyReason = this.policyDisabledFlows.get(name);
4579+
if (policyReason !== undefined) return policyReason;
45544580
// [#20725, ADR-0126 §7.3] The arming gate declined it onto a disabled
45554581
// packaged subflow — read from the record, for the policy line's
45564582
// reason, and ahead of the trigger branches for the gate's: with the

‎packages/services/service-automation/src/per-kernel-scheduled-work-policy.test.ts‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,124 @@ describe('AutomationServicePlugin — forwards the per-kernel policy (#19834)',
256256
}
257257
});
258258
});
259+
260+
// ─── [#21110] a host-injected OFF reports the HOST's reason ─────────
261+
//
262+
// The #19834 seam had no reason slot, so a kernel a host turned off for its
263+
// own reason (cloud's free plan) was reported with the deployment sentence —
264+
// "set OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true" — on a process where that
265+
// variable IS set, to a tenant who cannot set it. `ScheduledWorkPolicy` now
266+
// carries an optional `hostDisabledReason`, and `scheduledWorkDisabledReason`
267+
// is the one answer the bind log, the audit and the `/_status` row read.
268+
//
269+
// `getFlowRuntimeStates()` is the row `GET /automation/_status` serves,
270+
// verbatim (`packages/runtime/src/domains/automation.ts`, the `_status`
271+
// branch), so the status-door half is pinned on it here.
272+
273+
const HOST_REASON = 'Scheduled flows are not included in the Free plan; upgrade the plan to run them.';
274+
const HOST_OFF: ScheduledWorkPolicy = { ...OFF, hostDisabledReason: HOST_REASON };
275+
276+
/** A logger that keeps every `info` line — the bind log is said at `info`. */
277+
function recordingLogger() {
278+
const infos: string[] = [];
279+
const l: any = { info: (m: string) => void infos.push(String(m)), warn() {}, error() {}, debug() {} };
280+
l.child = () => l;
281+
return { logger: l, infos };
282+
}
283+
284+
/** Engine + trigger + one schedule flow, with the bind log captured. */
285+
function bindOneLogged(options?: ConstructorParameters<typeof AutomationEngine>[2]) {
286+
const { logger, infos } = recordingLogger();
287+
const engine = new AutomationEngine(logger, undefined, options);
288+
const rec = recordingTrigger();
289+
engine.registerTrigger(rec.trigger);
290+
engine.registerFlow('digest', scheduleFlow('digest'));
291+
// Every `info` line naming the flow: the bind log is the only one at this
292+
// point, and selecting it by the flow's name rather than by its framing
293+
// words keeps the refusal's wording out of the pin.
294+
const bindLog = infos.filter((m) => m.includes("'digest'"));
295+
return { engine, started: rec.started, bindLog };
296+
}
297+
298+
/** The three surfaces' reasons for 'digest': bind log line, audit, `/_status` row. */
299+
function reasonsOf(bound: ReturnType<typeof bindOneLogged>) {
300+
const audit = bound.engine.getTriggerBindingAudit();
301+
const row = bound.engine.getFlowRuntimeStates().find((s) => s.name === 'digest');
302+
return { audit, row, bindLog: bound.bindLog };
303+
}
304+
305+
describe("AutomationEngine — a host-injected OFF reports the host's reason (#21110)", () => {
306+
const PRIOR_SWITCH = process.env[SCHEDULED_WORK_ENV];
307+
const PRIOR_POSTURE = process.env[POSTURE_ENV];
308+
afterEach(() => {
309+
if (PRIOR_SWITCH === undefined) delete process.env[SCHEDULED_WORK_ENV];
310+
else process.env[SCHEDULED_WORK_ENV] = PRIOR_SWITCH;
311+
if (PRIOR_POSTURE === undefined) delete process.env[POSTURE_ENV];
312+
else process.env[POSTURE_ENV] = PRIOR_POSTURE;
313+
});
314+
315+
for (const [label, deployment] of [
316+
['deployment ON (the measured case: the variable IS set)', 'true'],
317+
['deployment OFF (unset)', undefined],
318+
] as const) {
319+
it(`${label}: the bind log, the audit and the /_status row all carry the host's reason`, () => {
320+
withDeployment(deployment);
321+
const bound = bindOneLogged({ scheduledWorkPolicy: HOST_OFF });
322+
expect(bound.started, 'the host-OFF kernel called its trigger').toEqual([]);
323+
const { audit, row, bindLog } = reasonsOf(bound);
324+
325+
expect(audit).toEqual([{ flowName: 'digest', triggerType: 'schedule', reason: HOST_REASON }]);
326+
expect(row).toMatchObject({ enabled: true, bound: false, triggerType: 'schedule', reason: HOST_REASON });
327+
expect(bindLog, 'the bind log is said once').toHaveLength(1);
328+
expect(bindLog[0]).toContain(HOST_REASON);
329+
// ⛔ The defect: the deployment switch named as the cause on a
330+
// kernel whose host decided. Pinned by absence on all three.
331+
for (const reason of [audit[0].reason, row?.reason, bindLog[0]]) {
332+
expect(reason).not.toContain(SCHEDULED_WORK_ENV);
333+
}
334+
});
335+
}
336+
337+
it('a host policy with enabled:false and NO reason keeps the deployment sentence, byte for byte', () => {
338+
withDeployment('true');
339+
const { audit, row, bindLog } = reasonsOf(bindOneLogged({ scheduledWorkPolicy: OFF }));
340+
expect(audit.map((a) => a.reason)).toEqual([SCHEDULED_WORK_DISABLED_REASON]);
341+
expect(row?.reason).toBe(SCHEDULED_WORK_DISABLED_REASON);
342+
expect(bindLog).toHaveLength(1);
343+
expect(bindLog[0]).toContain(SCHEDULED_WORK_DISABLED_REASON);
344+
});
345+
346+
it('CONTROL: an unset variable with no host policy keeps the deployment sentence on every surface', () => {
347+
withDeployment(undefined);
348+
const { audit, row, bindLog } = reasonsOf(bindOneLogged());
349+
expect(audit.map((a) => a.reason)).toEqual([SCHEDULED_WORK_DISABLED_REASON]);
350+
expect(row?.reason).toBe(SCHEDULED_WORK_DISABLED_REASON);
351+
expect(bindLog).toHaveLength(1);
352+
expect(bindLog[0]).toContain(SCHEDULED_WORK_DISABLED_REASON);
353+
});
354+
355+
it('the reason is read from the RECORDED refusal, not re-asked of the resolver at report time', () => {
356+
// A resolver may answer something else by the time the audit or the
357+
// status door runs. What is reported is the sentence of the reading
358+
// that refused.
359+
withDeployment('true');
360+
let current: ScheduledWorkPolicy = HOST_OFF;
361+
const engine = new AutomationEngine(silentLogger(), undefined, { scheduledWorkPolicy: () => current });
362+
engine.registerFlow('digest', scheduleFlow('digest'));
363+
current = { ...OFF, hostDisabledReason: 'a later, different reason' };
364+
expect(engine.getTriggerBindingAudit().map((a) => a.reason)).toEqual([HOST_REASON]);
365+
expect(engine.getFlowRuntimeStates().find((s) => s.name === 'digest')?.reason).toBe(HOST_REASON);
366+
});
367+
368+
it('through the plugin: a kernel booted with a host reason reports it under a deployment that is ON', async () => {
369+
withDeployment('true');
370+
const off = await bootKernel(HOST_OFF);
371+
try {
372+
expect(off.started).toEqual([]);
373+
expect(off.engine.getTriggerBindingAudit().map((a) => a.reason)).toEqual([HOST_REASON]);
374+
expect(off.engine.getFlowRuntimeStates().find((s) => s.name === 'digest')?.reason).toBe(HOST_REASON);
375+
} finally {
376+
await off.kernel.shutdown();
377+
}
378+
});
379+
});

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -587,9 +587,12 @@ export interface FlowRuntimeState {
587587
* field that tells them apart on the wire.
588588
*
589589
* [#17396 ruling G item 6] A flow left unarmed because package-authored
590-
* scheduled work is switched OFF on this deployment carries a DISTINCT
591-
* sentence — `SCHEDULED_WORK_DISABLED_REASON` (`@objectstack/types`),
592-
* which names the switch and its remedy — and ⛔ never reads as "binding
590+
* scheduled work is switched OFF carries a DISTINCT sentence — the
591+
* policy's reason, `scheduledWorkDisabledReason(policy)`
592+
* (`@objectstack/types`). [#21110] That is the host's `hostDisabledReason`
593+
* when a host-injected per-kernel `ScheduledWorkPolicy` carries one, else
594+
* `SCHEDULED_WORK_DISABLED_REASON`, which names the deployment switch and
595+
* its remedy — and ⛔ never reads as "binding
593596
* failed": a binding failure is a defect with an engineering remedy, while
594597
* this is a deployment policy with an operator one, and the two send the
595598
* reader to different places. Before this field existed the two reached

‎packages/triggers/trigger-schedule/README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,11 @@ several kernels in one process can give each kernel its own policy: pass the
9393
same `scheduledWorkPolicy` (a `ScheduledWorkPolicy` value or a resolver) to
9494
`AutomationServicePlugin`, `ScheduleTriggerPlugin` and
9595
`TimeRelativeTriggerPlugin`. Without it, the deployment switch decides, as
96-
before. A flow the policy leaves unarmed is reported with the same
97-
policy reason as a deployment-disabled one.
96+
before. A flow the policy leaves unarmed is reported the way a
97+
deployment-disabled one is, never as a binding failure. The reason is the
98+
policy's `hostDisabledReason` when the host sets one, so a host that turns one
99+
kernel off for its own reason (a plan, say) can say so. Without it, the reason
100+
is the deployment's sentence, which names `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`.
98101

99102
## Error isolation
100103

0 commit comments

Comments
 (0)