Repository navigation
service-automation: a restart re-arms the trigger of a ledger-disabled packaged flow — registerTrigger ignores the activation ledger, and every matching event then logs an ERROR claiming a run-history record that is never written #20677
Description
Activity
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsPath: automation — an administrator's switch-off survives a restart | 缺项 (
registerTriggerarms every matching flow without consulting the activation ledger, so a ledger-disabled packaged flow comes backbound:trueafter a cold boot, and each matching event logs an ERROR claiming a run-history record that is never written) | P1Triage: first grade —
bug·priority:p1·domain:services·area:workflow·pm:queue. Direction: one enablement gate at the single arming point, so no arming path can bypass the ledgerTriage: lands in
packages/services/service-automation/src/engine.ts(registerTrigger/activateFlowTrigger) ⇒domain:services. It is a finding of acceptance run #20674 (card #12438, epic #12150).Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-29T17:51Z. ⛔ Not a claim, ⛔ not a dispatch.Why p1, not p0.
- The kill switch holds: no run executes, so nothing is written behind the administrator's back.
- But the item's own contract is red on
main: 「a restart that re-arms the flow … is a FAIL against the whole point of ADR-0126 §7.2」. - After every deploy,
/_status(the Studio badge source) reports a switched-off flow as bound, and every matching event logs an ERROR that describes a record which doesn't exist.
Direction.
- One gate.
activateFlowTriggeritself refuses to arm a flowisFlowEnabledanswersfalsefor. ThenregisterFlow,registerTriggerand any later arming path all inherit it. ⛔ Don't patchregisterTriggeralone: that leaves the next path to repeat this. - The log line tells the truth. A trigger-fired failure says "recorded in the flow's run history" only when a run row is written. A
FLOW_DISABLEDrefusal that reaches it anyway (for example, a race) is logged as what it is, ⛔ not as a production failure. - Pins:
- A trigger type registered after
hydrateFlowActivations()doesn't arm a ledger-disabled flow. - A cold restart over the same store keeps
enabled:false, bound:false. - An enabled sibling still arms (the control).
- A trigger type registered after
- Release: ADR-0126's switch-off is live in shipped versions, so this is recommended for the next release, and it is on the triage seat's release list.
- addedarea:workflowApprovals and automation — the work that runs without a person driving itApprovals and automation — the work that runs without a person driving itbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2and removed
on Sep 29, 2026 objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 · 2026-09-29T18:00Z
Session:session_01XY5uCwTjZj7884yYtyur4H
Account:os-justin(the seat's linked user asGET /useranswers it; the card's assignee)
Branch:claude/issue-20677-ledger-disabled-stays-unbound
Worktree:objectstack-issue-20677
Domain:domain:services
Seat:domain:services(seat 1, seat post #6021)
Scope, per triage's grade5895639312:- One enablement gate in
activateFlowTriggeritself, so every arming path inherits it:registerFlow,registerTriggerand any later one. ⛔ Not aregisterTrigger-only patch. - A truthful log line: a trigger-fired failure claims a run-history record only when one is written, and a
FLOW_DISABLEDrefusal is logged as what it is. - The three pins triage names:
- a trigger type registered after
hydrateFlowActivations()does not arm a ledger-disabled flow; - a cold restart over the same store keeps
enabled:false, bound:false; - an enabled sibling still arms (the control).
File surface:packages/services/service-automation/src/engine.tsand its tests (flow-activation-ledger.test.ts, or a new one beside it), plus.changeset/20677-*.md(stop on a breach outside these; explain in the report)
Container & model:M,mode:subagent,model: opus(dispatch-gates --tierat14f80e23: no path-derived mandate, floor sonnet · default opus · ceiling fable; an arming-path invariant with a restart pin is design judgment ⇒ the default tier)
Clause-②: no
Thread-read: 5895639312
Serial constraints cleared:
- a trigger type registered after
- No open PR touches
packages/services/service-automation/(census of all 11 open PRs at the claim). - Today's merged
engine.tschanges (PR feat(spec,service-automation): a flow run's result carries the flow's authored label as flowLabel #20633, PR fix(trigger-api,service-automation): refuse an api flow with no per-flow secret, at arm time and at registration (#20529) #20551) are onmain. - This seat's in-flight dead tracker citations in the
domain:servicespackages (629 sites, 112 numbers, 152 files): the ruling C+D stage for this lane (from #20556) #20596 stage 5 is inservice-datasource, which is disjoint. dead tracker citations in thedomain:servicespackages (629 sites, 112 numbers, 152 files): the ruling C+D stage for this lane (from #20556) #20596'sservice-automationcomment stage waits behind this card.
Generated by Claude Code
- One enablement gate in
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 20677,
"status": "done",
"branch": "claude/issue-20677-ledger-disabled-stays-unbound",
"pr": "#20702",
"session": "session_01XY5uCwTjZj7884yYtyur4H",
"premise_still_valid": true,
"summary": "The card's reproduction was reached on current main (14f80e2) at the engine level: a boot replay over one activation store in plugin.ts's order (pull, hydrateFlowActivations, kernel:ready protocol sync, registerTrigger, seal) read { enabled: false, bound: true } on the first restart. That is the card's /_status shape, and 10 of the 11 new pins were red on the unfixed engine (commit 1a7ce83). The fix (commit 7589676, engine.ts only) puts ONE enablement gate in activateFlowTrigger: it returns before arming when isFlowEnabled is false, so registerFlow, registerTrigger and toggleFlow's enable branch all inherit it, and registerFlow's caller-side check is folded into it. The trigger-fired callback now logs a FLOW_DISABLED refusal at info with no run-history claim, and says 'recorded in the flow's run history' only for status 'failed'. A never-dispatched failure keeps the error line without that claim. A2 measured: activateFlowTrigger has three callers. policyDisabledFlows, the /_status reason and the binding audit are unchanged for enabled flows. One behaviour change beyond the card's path, pinned: toggleFlow(name, true) on an obsolete or invalid flow no longer arms it, and a status-disabled flow is not re-armed by a later registerTrigger (both were armed-but-refusing before). The re-enable path for a ledger-disabled flow still arms, also pinned. PR #20551's api secret refusal is untouched. The PR is draft, assigned to os-justin, and CI was in_progress at the report (31 check-runs: 18 in_progress, 10 success, 3 skipped, 0 failed).",
"tests": "At HEAD 7589676, the last commit; the package suite, typecheck, lint narrowing and gate union were all read after it. (1) pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/flow-activation-late-trigger.test.ts: 11 cases. On unfixed main: 'Tests 10 failed | 1 passed (11)', including 'restart 1: expected { enabled: false, bound: true } to deeply equal { enabled: false, bound: false }'. On the fix: green. (2) pnpm --filter @objectstack/service-automation test: 'Test Files 155 passed (155)', 'Tests 1924 passed (1924)', VERDICT command-exit 0. (3) pnpm --filter @objectstack/service-automation typecheck: exit 0 (tsc --noEmit plus 'check:test-typecheck: OK'); tsc -p tsconfig.test.json --listFiles lists the new test (1 hit). (4) Ablation via scripts/ablation-replace.mjs in wrap mode, with a trap 'git checkout HEAD -- ABS_PATH' on EXIT INT TERM. The subject resolves from src through the relative ./engine.js import, so there is no dist leg. (a) Deleting the gate line 'if (!this.isFlowEnabled(flowName)) return;' (anchor x1 then x0, blob 566ed4d67921 then d09694f76678) gave 'Tests 8 failed | 3 passed (11)': every arming pin went red, and the 3 log-line pins do not depend on the gate. (b) FLOW_DISABLED branch changed to 'if (false) {': 'Tests 1 failed | 10 passed', with the info pin red ('expected [ Array(1) ] to deeply equal []'). (c) History condition forced true: 'Tests 1 failed | 10 passed', with the never-dispatched pin red. Each restore was proven by 'ok restored: blob == HEAD (566ed4d67921) and git diff HEAD is empty', and git status --porcelain was empty. (5) Gates: node scripts/pm/dispatch-gates.mjs --commands, re-derived at 7589676, matched the dispatch's 62 commands. With exit codes captured before any pipe, 60 exit 0. NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, both exit 3 PREREQUISITE NOT MET because they need the whole workspace's dist/; CI owns them. --ran: '62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED'. Roster families check-changeset-fixed, check:authz-resolver, check:error-code-casing and check:filter-alias-parity: exit 0. Also run, exit 0: check:durability-log-level, check:startup-registry-verdict, check:nul-bytes. (6) Lint narrowing: eslint --no-inline-config --format json on engine.ts and the new test gave '2 files, 0 errors, 0 warnings'. The changeset .md matches no config (print-config undefined). No type-aware linting is configured (no parserOptions.project or projectService), so the diff cannot move verdicts on untouched files. (7) The plugin-level (LiteKernel) cold boot is NOT MEASURED: it needs a new data-engine double under check:engine-double-contract, so the engine-level boot replay stands in for it.",
"mcp_calls": "0 — no MCP GitHub tool called",
"api_writes": "3 — each one repository_dispatch through the fleet-write relay as objectstack-fleet[bot]: (1) pr_create, i.e. POST /repos/objectstack-ai/objectstack/pulls (draft, #20702, body read back byte-identical, 11029 bytes); (2) label-write assign, i.e. POST /repos//issues/20702/assignees os-justin (read back: assignee os-justin; labels size/m, documentation, tests and tooling come from bots, and none were written by this run); (3) post-stamped os-dev-report, i.e. POST /repos//issues/20677/comments. Plus git push (not REST). All other GitHub traffic was unauthenticated public GET reads.",
"deviations": [
"Clause-② stays no: no export, option, route or response shape moved.",
"The cold-restart pin is engine-level (one shared InMemoryFlowActivationStore, plugin.ts boot order replayed), not a LiteKernel boot of the real plugin, for the reason in tests (7).",
"Not merged forward: origin/main moved 3 commits since base (3b47a69, 0be8984, f379f57), none touching packages/services/service-automation or packages/spec/src/contracts; CI and the merge queue validate the merge ref.",
"Beyond the dispatch list I also ran check:durability-log-level and check:startup-registry-verdict, because the diff edits a log level and an arming decision."
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: none · noted, not filed — a comment in packages/services/service-automation/src/api-trigger-secret-registration.test.ts says an obsolete flow 'is re-enabled by a toggle'. Under ADR-0126 toggleFlow moves only the ledger bit, so it never re-enables a status-disabled flow. This is comment drift outside the claim's file surface.",
"carrier: none · noted, not filed — pre-existing edge: when recordLog throws on execute()'s failure arm, the result still carries status 'failed', so the trigger-fired line says the failure is recorded while the bookkeeping ERROR line says the row never landed. AutomationResult has no write-failed channel, the bookkeeping line is itself loud, and it was not measured through a public door."
]
}objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsACCEPT — PR #20702 at
75896761·domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T18:41ZChecklist, read on GitHub rather than from the report:
- Shape: a draft onto
main, assignedos-justin.Fixes #20677is line 1 and the only closing keyword, andClause-②: nois line 2. 3 files, +403 / −17. No governed path. - Read at source, against triage's direction (
5895639312):- One gate:
if (!this.isFlowEnabled(flowName)) return;sits insideactivateFlowTrigger, the one point every arming path crosses:registerFlow(its caller-side check folded in),registerTrigger, andtoggleFlow's enable branch.registerTrigger's loop carries a ⛔ note against a second check. Not aregisterTrigger-only patch. - The truthful line:
- a
FLOW_DISABLEDrefusal is logged atinfo, saying nothing ran and no run-history row records it; - the
ERRORline claims "recorded in the flow's run history" only forstatus: 'failed'(a run that dispatched); - a never-dispatched failure keeps the error without that claim.
- a
- This reuses the file's logger. There is no second dialect.
- One gate:
- Evidence:
- Reached on
main: an engine-level boot replay inplugin.ts's order read{ enabled: false, bound: true }on the first restart, the card's shape. The pins were committed red first: 10 of 11 failed. - Ablations: removing the gate turned all 8 arming pins red. Each log-line branch reddens its own pin. Every restore was proven.
service-automation1924 passed;typecheckexit 0.- 60 / 62 derived gates plus the 4 ⛔ rosters exit 0.
check:dual-build-cjs-loadsandcheck:type-check-debtare NOT MEASURED locally, lacking a whole-workspace build; CI decides them.
- Reached on
- Behaviour beyond the card's path, accepted and pinned: it follows from the gate reading
isFlowEnabled, which composes both disable dimensions.- Re-enabling the ledger bit of an
obsolete/invalidflow no longer arms it; every run it fired would be refused. - A status-disabled flow is not re-armed by a later trigger registration.
- The ledger re-enable path still arms.
- Re-enabling the ledger bit of an
- Deviation accepted: the cold-restart pin is engine-level, replaying the boot order over one activation store, rather than a LiteKernel boot. A plugin-level boot would need a new engine double under
check:engine-double-contract. - PR fix(trigger-api,service-automation): refuse an api flow with no per-flow secret, at arm time and at registration (#20529) #20551's arm-time
apisecret refusal is untouched.
Findings:
- A test comment in
api-trigger-secret-registration.test.tssays an obsolete flow "is re-enabled by a toggle", which is now untrue under ADR-0126 → Acceptance notes. It is comment drift outside the surface and rides that file's next touch. - When
recordLogthrows onexecute()'s failure arm, the result still saysfailed, so the line claims a record while the bookkeepingERRORsays the row never landed → Acceptance notes. This is pre-existing and not measured at a public door. The bookkeeping line is loud on its own.
Landing: waits for every check to be green and for the at-tier contract review the
.changesetprose owes. Triage marks this card recommended for the next release.
Generated by Claude Code
- Shape: a draft onto
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsContract review
Served-tier:
CONTRACT_REVIEW_TIER
Head-sha:758967616e1f8078c884bbea146c5980c19e7ad2
Local-runs: noneInputs, and nothing else: card #20677 (body and all four comments — triage
5895639312, claim5895758242, os-dev-report5896376202, ACCEPT5896413509), PR #20702 (body, file list, the net diff againstmainfrom merge-base14f80e23: 3 files, +403 / −17), and the check-runs on this head. Read-only:git show/git diffagainst the shared object store; nothing built, run or re-run. This verdict is derived from the diff and the card, not adopted from the ACCEPT.① Derived judgments
- Accept-set: none moved — right.
packages/specis untouched; no Zod schema, metadata key, route input, response shape or env var changes.execute()'sFLOW_DISABLEDexit anddescribeDisabledFloware byte-identical. - Exports: none moved — right.
activateFlowTriggeris private,src/index.tsis untouched, and the new test importsdeployment-switch.test-support.ts, a pre-existing helper that is outside the tsup entry and exported by nothing. getFlowRuntimeStates()/GET /api/v1/automation/_status: a ledger-disabled flow whose trigger type registers afterhydrateFlowActivations()now readsbound: false(wastrue) — right. That is the card's contract and ADR-0126 §7.2 ("the install-level row may also unbind the trigger").reasonis unchanged becausedescribeUnboundReasonalready answeredundefinedfor a disabled flow before reading anything else, sogetTriggerBindingAudit()is unchanged too — the restart pin asserts the audit stays empty.- One gate at the one arming point — right, and it is triage's direction, not a
registerTrigger-only patch. Read at head:activateFlowTriggerhas exactly three callers (registerTrigger:3382,registerFlow:4327,toggleFlow's enable branch :4718), andtrigger.start(has exactly one call site inengine.ts(:3706), inside it.registerFlow's caller-sideif (this.isFlowEnabled(name))is folded into the gate; equivalent, sinceflowStatusDisabledis written at :4296 before the re-arm at :4327.isFlowEnabledcomposes both disable dimensions (ledger andobsolete/invalidstatus), so the gate inherits both. - Behaviour beyond the card's path:
toggleFlow(name, true)on anobsolete/invalidflow, andregisterTriggerfor a status-disabled flow, no longer arm — right. Before, both produced an armed flow every event of whichexecute()refused withFLOW_DISABLEDand logged at ERROR: the card's defect through another door. The declared contract (FlowRuntimeState.enabled: "a disabled flow stays registered and never runs";IAutomationService.toggleFlow: "Enable or disable a flow") promises nobound: truefor a disabled flow, so this is a pull back to the contract, not a widening or narrowing of it. Both are pinned (the enable path still arms, second case;a STATUS-disabled flow…). FLOW_DISABLEDreaching the trigger-fired callback is logged atinfowith no run-history claim — right. By the degradation rule (AGENTS.md "Degradation log levels"): nothing the system claims persisted failed to land, and the refusal is the switch working. The only residue paths are an event in flight at the switch-off (benign) and astop()that threw, whichdeactivateFlowTriggeralready reports atwarn("may keep firing until the process restarts") where it happens.- The
errorline claims "recorded in the flow's run history" only whenresult.status === 'failed'— right.statusis the contract's own discriminator (AutomationResultJSDoc: trigger-time refusals carry nostatus; the ran-and-failed exit stamps'failed'after writing its row, as doretryExecution's exits). The call keeps theerror(message, undefined, meta)shape — one physical line, envelope in meta third — so the three pins inengine-residual-log-cause.test.tson this line still hold. One asymmetry, stated by the dev in the PR body:FLOW_INPUT_SCHEMA_INVALIDwrites one failed row yet carries nostatus, so its line now says "refused before it dispatched" (true — no node ran) and omits the row. Less informative, not false; no false claim remains on that line. policyDisabledFlows: a disabled scheduled flow now returns before the scheduled-work policy gate and records no policy refusal — right, and invisible on the wire, sincedescribeUnboundReasonreturns before reading that set for a disabled flow.- The new test file is a faithful pin — right. The restart case replays
plugin.ts's real order, verified at head:start()→registerFlow(:992) →hydrateFlowActivations()(:1035);kernel:ready(:1122) → protocol re-syncregisterFlow(:2089);kernel:bootstrapped(:1138) →sealNodeTypeVocabulary()(:1166); trigger plugins register atkernel:ready. It reads no filesystem path (the cross-package-inputs gate is not implicated), covers all four trigger kinds a plugin registers atkernel:readywith an enabled sibling as the control, and was committed red first (1a7ce83b7, 10 of 11 red on the unfixed engine). Each of the three ablations reddens exactly the pins that depend on the ablated line. - Docs: nothing owed — right.
content/docs/**andapps/docs/**are untouched, and at head nothing outsideengine.ts, the new test and the CHANGELOG history describes the old line; the CHANGELOG entries (e218483) describe theTrigger-fired run of flow '…' failedlead phrase, which theerrorline keeps.
② Semver level
.changeset/20677-ledger-disabled-stays-unbound.mddeclares'@objectstack/service-automation': patch. The package is a released member of the changesetsfixedgroup at 17.5.0; a bug fix in a released package takespatch, neverskip-changeset(AGENTS.md Post-Task Implement ObjectStack protocol specification with Zod schemas and TypeScript interfaces #3). No export, option, route or response shape moves; a log line's wording and level are not a published contract.patchis right.Clause-②: no— right. It is line 2 of the PR body and in the changeset body, matching the claim (5895758242). The criterion is whether the card widens the accept-set or expands the public surface; this diff pulls behaviour back to the declared contract (FlowRuntimeState, ADR-0126 §7.2, theFLOW_DISABLEDclassification) and adds nothing an author or consumer can newly write or read. No(widening)/(narrowing)arm and no ADR-0087 marker, correctly: the changeset is not breaking.- Changeset prose, sentence by sentence against the diff — holds. The toggle route, the
/_statusshape, the ERROR line's quoted claim, the three arming paths, theinfolevel, "recorded … only for a run that dispatched and failed", the obsolete-flow re-enable note, and "The runtime refusal (FLOW_DISABLED) and its message are unchanged" (describeDisabledFlowuntouched) are each true of the code at this head. Check Changeseton the head: success.
③ Boundary flags
Dev deviations (
os-dev-report5896376202):- "Clause-② stays no" → answered: right (② above).
- "The cold-restart pin is engine-level, not a LiteKernel boot" → answered, accepted. The defect sits in the engine's arming path;
plugin.tsis untouched and the replay follows its verified order (①.9). A plugin-level boot needs a new data-engine double undercheck:engine-double-contract, outside the claim's file surface. The card's five-step HTTP reproduction stays with the acceptance run (QA run · card:12438 (15/15) · 6bff748b · 2026-09-29 · 6 PASS / 1 PARTIAL / 8 FAIL / 0 BLOCKED / 0 NOT-RUN #20674) to re-read on a shipped build; not owed by this PR. - "Not merged forward:
origin/mainmoved 3 commits" → answered, accepted.mainis now 5 commits past merge-base14f80e23(compare APIbehind_by: 5; localrev-listagrees); none of the five touchespackages/services/service-automation/orpackages/spec/src/contracts/(git logover those paths is empty). No semantic conflict; the queue validates the merge ref. - "Also ran
check:durability-log-levelandcheck:startup-registry-verdict" → answered: additive, appropriate to a log-level and arming edit; CI'sLint & Repo Gatesdecides them for the record.
open_questions:[]— nothing to answer.Out-of-scope findings (
carrier: none):- Comment drift in
api-trigger-secret-registration.test.ts:79-80 ("anobsoleteflow is re-enabled by a toggle") → answered: a note is the right carrier. Comment-only; that case asserts registration refusal, not toggle arming, and passes at this head. It rides the file's next touch. recordLogthrowing onexecute()'s failure arm leavesstatus: 'failed'on the result while the catch's own ERROR says the row never landed → answered: not a FAIL finding, and not escalated to a card.AutomationResulthas no write-failed channel; adding one is apackages/speccontract change (Clause-② territory) that this card does not own. The catch's bookkeeping ERROR is itself loud and names the failed write, so no reader is left with only the false half. Not measured through a public door. The note stands as written.
Check-runs on
75896761, read at 2026-09-29T18:52Z: 34 runs — 30 success, 3 skipped by path (Console Pin Gate, Build Docs, Packed-tarball smoke), 0 failed, 1 in progress (Lint & Repo Gates). Their conclusions are the gate verdicts; the one in progress is the landing gate's to read, not this record's, and landing waits for it (every check green, not a required subset).Implemented-by:
claude/issue-20677-ledger-disabled-stays-unbound
Reviewed-by:session_01XY5uCwTjZj7884yYtyur4HVERDICT: PASS
Generated by Claude Code
- Accept-set: none moved — right.
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsLanded ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T19:16ZPR #20702 merged through the merge queue as
defc7f7bonorigin/main, andFixes #20677closed this cardcompleted.- The contract review PASS on the landed head is
5896590453. - What now holds:
- One enablement gate in
activateFlowTriggermeans no arming path re-arms a ledger-disabled flow, including a trigger that registers atkernel:readyafter hydration./_statusreadsenabled: false, bound: falseacross restarts. - A
FLOW_DISABLEDtrigger refusal logs atinfowith no run-history claim, and theERRORline claims a record only for a run that dispatched.
- One enablement gate in
- For the release list: triage marked this card recommended for the next release, and the fix is in
@objectstack/service-automationas apatch. - Next on the same file: service-automation: the packaged-subflow disable refusal tells the admin to disable the calling flow first, but a disabled caller still blocks the disable — the prescribed remedy can never complete #20678 (the packaged-subflow disable sequence) edits
toggleFlow. It was held serial behind this PR and is dispatched now, on the merged code. - In the same act, this seat removes
pm:dispatchedand the assignee.
Generated by Claude Code
- The contract review PASS on the landed head is
QA-source: #20674 · automation.packaged-flow-disable-durable · c3
What happens
A packaged flow switched off through the ADR-0126 activation ledger comes back bound after a cold restart. Execution is still refused (no run is recorded, so the kill switch itself holds), but the trigger is armed again, every matching mutation fires it, and each firing is logged at ERROR as a failed trigger-fired run whose failure "is recorded in the flow's run history" — while the run history holds nothing.
Measured on
mainat6bff748b(showcase,objectstack dev, file DB), reproduced on two consecutive cold boots over the same DB file.Reproduction
POST /api/v1/automation/showcase_urgent_task_alert/toggle {"enabled":false}→ 200.GET /api/v1/automation/_status→enabled:false, bound:false; the ledger row readsactive: false.OS_LOG_LEVEL=infoshows the lines below).GET /api/v1/automation/_status. Expected:enabled:false, bound:false. Actual:enabled:false, bound:true, stable after waiting.[Automation] Activation ledger: 1 packaged flow(s) are switched off for this installation and were left unbound — 'showcase_urgent_task_alert'.→[Automation] Bound 30 flow(s) from the protocol at kernel:ready→Trigger registered: record_change.POST /api/v1/data/showcase_taskwithpriority: 'urgent'(plus the requiredtitle,project,status): the server logsERROR Trigger-fired run of flow 'showcase_urgent_task_alert' failed (trigger 'record_change') — no caller holds this result and nothing retries the run; the terminal failure is recorded in the flow's run history…carrying the FLOW_DISABLED sentence;GET /api/v1/automation/showcase_urgent_task_alert/runs(also?status=failed) shows no new run.Mechanism (read from source at
6bff748b)AutomationEngine.registerTrigger(packages/services/service-automation/src/engine.ts) iterates every registered flow not inboundFlowTriggersand callsactivateFlowTrigger(name)when its trigger type matches — without consultingisFlowEnabled(name).activateFlowTriggerhas no enablement check either.kernel:ready, which fires afterAutomationServicePlugin.start()pulled the flows and ranhydrateFlowActivations()(which correctly unbound the flow and logged so).registerFlowdoes honour the ledger ("A ledger-disabled flow is NOT re-armed here…"); this second arming path does not. The engine pins inflow-activation-ledger.test.tscover hydration and re-registration, not a trigger registered after hydration.Why it matters
/_status— the Studio status badges' source — reports a disabled flow as bound after every deploy.main. RUNNER rule 7 applies: re-derive independently before acting.Expected
After a restart a ledger-disabled flow stays unbound regardless of when its trigger type registers; no trigger-fired failure is logged for it.
Full evidence chain: #20674 (F-2).
Generated by Claude Code