Repository navigation
Commit defc7f7
Fixes #20677
Clause-②: no
## What was wrong
A packaged flow switched off through the ADR-0126 activation ledger came
back armed after a cold restart. At boot
`AutomationServicePlugin.start()` pulls the flows and runs
`hydrateFlowActivations()`, which leaves a switched-off flow unbound.
The trigger plugins register later, at `kernel:ready`, and
`AutomationEngine.registerTrigger` armed every registered, unbound flow
of the matching type through `activateFlowTrigger` without asking
whether the flow may run. So `GET /api/v1/automation/_status` reported
`enabled: false, bound: true`. Every matching event then fired a run
that `execute()` refused with `FLOW_DISABLED`, and the trigger-fired
callback logged it at ERROR as a failed run whose failure "is recorded
in the flow's run history". No row was written.
**The card's reproduction, reached on current `main` at the engine
level** (`14f80e23`, the pins in commit `1a7ce83b7` run against the
unfixed engine): a boot replay over one activation store, in
`plugin.ts`'s order (pull, hydrate, `kernel:ready` protocol sync,
trigger registration, seal), reads on the first restart:
```text
AssertionError: restart 1: expected { enabled: false, bound: true } to deeply equal { enabled: false, bound: false }
```
That is the card's `/_status` shape. On the unfixed code 10 of the 11
new cases are red, and the one control (a run that dispatched and
failed) is green.
## What changes (`packages/services/service-automation/src/engine.ts`
only)
- **One enablement gate, in `activateFlowTrigger` itself.** It refuses
to arm a flow `isFlowEnabled` answers `false` for, before the
scheduled-work policy gate and before the trigger lookup.
`registerFlow`, `registerTrigger`, the enable half of `toggleFlow`, and
any arming path added later inherit it. `registerFlow`'s own caller-side
`if (this.isFlowEnabled(name))` is folded into the gate, so there is one
check, not two. `registerTrigger`'s loop is unchanged; only its comment
now names the gate.
- **The trigger-fired line states only what happened.**
- A `FLOW_DISABLED` answer that still reaches the callback (an event in
flight at the switch-off, or a trigger whose `stop()` failed) is logged
at `info`: the flow is disabled, the run was refused before it started,
nothing ran, and no run-history row records it. It is no longer logged
at `error`.
- Every other failure keeps the existing `error` line. That line says
"the terminal failure is recorded in the flow's run history" only when
the result carries `status: 'failed'`, the verdict `execute()`'s
ran-and-failed exit and `retryExecution`'s exit set after writing their
failed row. A never-dispatched answer (no `status`) gets the same line
with "it was refused before it dispatched" in place of the history
claim.
- Same logging shape as before, with the envelope in the structured
slot. No new log dialect.
## The dispatch's mechanism assumptions, measured
- **A1: confirmed.** `registerTrigger` (was about :3368) called
`activateFlowTrigger(name)` for every unbound flow of the matching type.
Neither method consulted `isFlowEnabled`. `registerFlow`'s only ledger
check was the caller-side guard around `activateFlowTrigger` (was
:4271). The one at :4257 guards the node-type warning, not arming. The
ordering is in `plugin.ts`: hydrate runs in `start()`, and the protocol
sync and trigger plugins run at `kernel:ready`.
- **A2: callers of `activateFlowTrigger`.** There are three:
`registerTrigger`, `registerFlow` and `toggleFlow`'s enable branch. The
only refused-flow bookkeeping is `policyDisabledFlows` (the
scheduled-work refusal record). A disabled flow now returns before the
policy gate, so it records no policy refusal. `describeUnboundReason`
already answered `undefined` for a disabled flow, so neither the
`/_status` `reason` nor `getTriggerBindingAudit()` changes. `/_status`'s
`bound` reads `boundFlowTriggers`, which is now `false` for the flow.
- **One behaviour change beyond the card's path:** `toggleFlow(name,
true)` on a flow whose `status` is `obsolete` or `invalid` no longer
arms it. Before, it was armed and every event it fired was refused with
`FLOW_DISABLED` (the status dimension) and logged at ERROR, the same
defect through the toggle. A status-disabled flow registered before its
trigger was re-armed by `registerTrigger` too. Both are pinned.
- The enable path for a ledger-disabled flow still arms, and that is
pinned as well.
- **A3: when `FLOW_DISABLED` reaches the log.** `execute()`'s disabled
exit returns before a run id is minted and before any `recordLog`, so no
row is ever written for it. Before the fix, it reached the ERROR line on
every matching event of a re-armed flow. With the gate, it is reached
only by the residue named above.
- The run-row fact is `status: 'failed'`. The four `status`-less failure
exits are flow not found, `FLOW_DISABLED`, `FLOW_NO_START_NODE` and
`FLOW_INPUT_SCHEMA_INVALID`. The last one does write a failed row but
carries no `status`, so the line makes no history claim for it: true, if
less informative.
**PR #20551's arm-time `api` secret refusal is untouched.** The gate
sits before `trigger.start`. `validateApiTriggerSecret` at registration
and `ApiTrigger.start()`'s throw (into the existing Failed-to-bind
`warn`) are unchanged, and `api-trigger-secret-registration.test.ts` is
green.
## Tests (all read at `758967616`, the last commit)
- New `src/flow-activation-late-trigger.test.ts`, 11 cases:
- A trigger registered after `hydrateFlowActivations()`, for each of
`record_change`, `schedule`, `time_relative` and `api`: the switched-off
flow stays `{ enabled: false, bound: false }`, and its enabled sibling
of the same kind arms (the control).
- The status dimension through the same path.
- The enable path re-arms a hydrated-disabled flow, and the store row
reads `active: true`.
- Re-enabling the ledger bit of an `obsolete` flow does not arm it.
- A cold restart over one store, twice: `enabled: false, bound: false`
on both boots, the sibling bound, and the binding audit empty.
- The three log-line cases: `FLOW_DISABLED` is logged at `info` with no
history claim and `listRuns` is empty. A dispatched failure is logged at
`error` with the claim and one `failed` row. A never-dispatched failure
is logged at `error` with no claim and no row.
- `pnpm --filter @objectstack/service-automation test`: `Test Files 155
passed (155)`, `Tests 1924 passed (1924)`.
- `pnpm --filter @objectstack/service-automation typecheck`: exit 0
(`tsc --noEmit`, then `check:test-typecheck: OK`). `tsc -p
tsconfig.test.json --listFiles` lists the new test file (1 hit).
- **Ablation**, via `scripts/ablation-replace.mjs` in wrap mode (anchor
must hit once, the restore is armed on EXIT/INT/TERM, and a shell trap
`git checkout HEAD --` sits behind it). The subject resolves from `src/`
through the relative `./engine.js` import, so no `dist/` leg applies.
- **Gate deleted** (`if (!this.isFlowEnabled(flowName)) return;`, anchor
x1 then x0, blob `566ed4d67921` then `d09694f76678`): `Tests 8 failed |
3 passed (11)`. The 8 are every arming pin. For example, `restart 1:
expected { enabled: false, bound: true } to deeply equal { enabled:
false, bound: false }`. The 3 log-line cases do not depend on the gate
and stay green.
- **`FLOW_DISABLED` branch disabled:** `Tests 1 failed | 10 passed`. The
failing case is the `info` pin: `expected [ Array(1) ] to deeply equal
[]`, where the array holds an error line.
- **History condition forced true:** `Tests 1 failed | 10 passed`. The
failing case is the never-dispatched pin, which now claims a run-history
row.
- **Every restore proven:** `ok restored: blob == HEAD (566ed4d)
and git diff HEAD is empty`. `git status --porcelain` is empty
afterwards.
## Gates
- `node scripts/pm/dispatch-gates.mjs --commands` re-derived against the
real diff gives the same 62 commands as the dispatch list, byte-for-byte
after sort.
- Every exit code was captured before any pipe. 60 exit 0.
- 2 are **NOT MEASURED**: `pnpm check:dual-build-cjs-loads` and `pnpm
check:type-check-debt` both exit 3 (PREREQUISITE NOT MET: they read the
whole workspace's built `dist/`, and this worktree built only this
package's dependency closure). CI builds that and runs both.
- `--ran`: `62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED
(2 DERIVED from a recorded exit 3)`.
- The roster families printed outside the runnable list all exit 0:
`node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`,
`pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`.
Added for the log-level and arming edit, also exit 0: `pnpm
check:durability-log-level` and `pnpm check:startup-registry-verdict`.
- **Lint, a declared narrowing** (`pnpm lint` over the whole repo is
CI's):
- Run: `eslint --no-inline-config --format json` on `engine.ts` and the
new test file.
- Result: 2 files, 0 errors, 0 warnings.
- The changeset `.md` has no matching config (`--print-config` answers
`undefined`, and the JSON says "File ignored because no matching
configuration was supplied"), so it is outside eslint's population.
- `eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project` or `projectService`), so this diff cannot move a
verdict on any untouched file.
- `pnpm check:nul-bytes`: exit 0. A control-byte self-scan of the three
changed files finds nothing.
## Changeset
`.changeset/20677-ledger-disabled-stays-unbound.md` is a `patch` on
`@objectstack/service-automation`. No export, option, route or response
shape moves.
## Acceptance notes
- The cold-restart pin is engine-level: it replays `plugin.ts`'s boot
order over one shared `InMemoryFlowActivationStore`. A LiteKernel boot
of the real plugin was not built. The plugin attaches the durable ledger
only through an `objectql` data engine with find/insert/update, so it
would need a new data-engine double under
`check:engine-double-contract`. The defect lives in the engine's arming
path.
- A test comment in `api-trigger-secret-registration.test.ts` says "an
`obsolete` flow is re-enabled by a toggle". Under ADR-0126 the toggle
moves only the ledger bit, so it never re-enables a status-disabled
flow. This is comment drift, outside this card's file surface. Carrier:
none.
- A pre-existing edge, not filed: when `recordLog` itself throws on
`execute()`'s failure arm, the result still carries `status: 'failed'`.
The trigger-fired line then says the failure is recorded, while the
separate ERROR line from that catch says the row never landed. The
result has no channel for "the write failed", and the bookkeeping line
is itself loud. Carrier: none.
- `origin/main` has moved 3 commits since the base (`3b47a693c`,
`0be898499`, `f379f57f4`). None touches
`packages/services/service-automation` or `packages/spec/src/contracts`,
so the branch is not merged forward. CI and the merge queue validate the
merge ref.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent ed54768 commit defc7f7
3 files changed
Lines changed: 403 additions & 17 deletions
File tree
- .changeset
- packages/services/service-automation/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3371 | 3371 | | |
3372 | 3372 | | |
3373 | 3373 | | |
| 3374 | + | |
| 3375 | + | |
| 3376 | + | |
| 3377 | + | |
3374 | 3378 | | |
3375 | 3379 | | |
3376 | 3380 | | |
| |||
3584 | 3588 | | |
3585 | 3589 | | |
3586 | 3590 | | |
3587 | | - | |
3588 | | - | |
| 3591 | + | |
| 3592 | + | |
| 3593 | + | |
3589 | 3594 | | |
3590 | 3595 | | |
3591 | 3596 | | |
| 3597 | + | |
| 3598 | + | |
| 3599 | + | |
| 3600 | + | |
| 3601 | + | |
| 3602 | + | |
| 3603 | + | |
| 3604 | + | |
| 3605 | + | |
| 3606 | + | |
| 3607 | + | |
| 3608 | + | |
| 3609 | + | |
| 3610 | + | |
| 3611 | + | |
| 3612 | + | |
| 3613 | + | |
| 3614 | + | |
| 3615 | + | |
| 3616 | + | |
| 3617 | + | |
| 3618 | + | |
3592 | 3619 | | |
3593 | 3620 | | |
3594 | 3621 | | |
| |||
3661 | 3688 | | |
3662 | 3689 | | |
3663 | 3690 | | |
| 3691 | + | |
| 3692 | + | |
| 3693 | + | |
| 3694 | + | |
| 3695 | + | |
| 3696 | + | |
| 3697 | + | |
| 3698 | + | |
| 3699 | + | |
| 3700 | + | |
| 3701 | + | |
| 3702 | + | |
| 3703 | + | |
| 3704 | + | |
| 3705 | + | |
3664 | 3706 | | |
3665 | 3707 | | |
3666 | | - | |
3667 | | - | |
3668 | | - | |
3669 | | - | |
3670 | | - | |
3671 | | - | |
3672 | | - | |
3673 | | - | |
| 3708 | + | |
| 3709 | + | |
| 3710 | + | |
| 3711 | + | |
| 3712 | + | |
| 3713 | + | |
| 3714 | + | |
3674 | 3715 | | |
| 3716 | + | |
3675 | 3717 | | |
| 3718 | + | |
| 3719 | + | |
| 3720 | + | |
| 3721 | + | |
| 3722 | + | |
| 3723 | + | |
| 3724 | + | |
| 3725 | + | |
| 3726 | + | |
| 3727 | + | |
| 3728 | + | |
3676 | 3729 | | |
3677 | 3730 | | |
3678 | 3731 | | |
| |||
4263 | 4316 | | |
4264 | 4317 | | |
4265 | 4318 | | |
4266 | | - | |
4267 | | - | |
4268 | | - | |
4269 | | - | |
| 4319 | + | |
| 4320 | + | |
| 4321 | + | |
| 4322 | + | |
| 4323 | + | |
| 4324 | + | |
| 4325 | + | |
4270 | 4326 | | |
4271 | | - | |
4272 | | - | |
4273 | | - | |
| 4327 | + | |
4274 | 4328 | | |
4275 | 4329 | | |
4276 | 4330 | | |
| |||
4657 | 4711 | | |
4658 | 4712 | | |
4659 | 4713 | | |
| 4714 | + | |
| 4715 | + | |
| 4716 | + | |
4660 | 4717 | | |
4661 | 4718 | | |
4662 | 4719 | | |
| |||
0 commit comments