Repository navigation
Description
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 main at 6bff748b (showcase, objectstack dev, file DB), reproduced on two consecutive cold boots over the same DB file.
Reproduction
- Boot with a file DB; as the seeded admin
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. - Stop the server process entirely; boot again over the same DB file (
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.- Boot-log order:
[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.- Trigger plugins register their trigger at
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.- An administrator's deliberate switch-off produces an ERROR line per matching event, and that line claims a run-history record that does not exist, which reads as a production failure.
- The item's P0 contract ("a restart that re-arms the flow (enabled or bound true…) is a FAIL against the whole point of ADR-0126 §7.2") is red on
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
Activity
objectstack-fleet commentedon Sep 29, 2026
Path: 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.
main: 「a restart that re-arms the flow … is a FAIL against the whole point of ADR-0126 §7.2」./_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.
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.FLOW_DISABLEDrefusal that reaches it anyway (for example, a race) is logged as what it is, ⛔ not as a production failure.hydrateFlowActivations()doesn't arm a ledger-disabled flow.enabled:false, bound:false.objectstack-fleet commentedon Sep 29, 2026
Claim: PM loop round 1 · 2026-09-29T18:00Z
Session:
session_01XY5uCwTjZj7884yYtyur4HAccount:
os-justin(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-20677-ledger-disabled-stays-unboundWorktree:
objectstack-issue-20677Domain:
domain:servicesSeat:
domain:services(seat 1, seat post #6021)Scope, per triage's grade
5895639312:activateFlowTriggeritself, so every arming path inherits it:registerFlow,registerTriggerand any later one. ⛔ Not aregisterTrigger-only patch.FLOW_DISABLEDrefusal is logged as what it is.hydrateFlowActivations()does not arm a ledger-disabled flow;enabled:false, bound:false;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:
packages/services/service-automation/(census of all 11 open PRs at the claim).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.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
objectstack-fleet commentedon Sep 29, 2026
os-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 commentedon Sep 29, 2026
ACCEPT — PR #20702 at
75896761·domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T18:41ZChecklist, read on GitHub rather than from the report:
main, assignedos-justin.Fixes #20677is line 1 and the only closing keyword, andClause-②: nois line 2. 3 files, +403 / −17. No governed path.5895639312):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.FLOW_DISABLEDrefusal is logged atinfo, saying nothing ran and no run-history row records it;ERRORline claims "recorded in the flow's run history" only forstatus: 'failed'(a run that dispatched);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.service-automation1924 passed;typecheckexit 0.check:dual-build-cjs-loadsandcheck:type-check-debtare NOT MEASURED locally, lacking a whole-workspace build; CI decides them.isFlowEnabled, which composes both disable dimensions.obsolete/invalidflow no longer arms it; every run it fired would be refused.check:engine-double-contract.apisecret refusal is untouched.Findings:
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.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
objectstack-fleet commentedon Sep 29, 2026
Contract review
Served-tier:
CONTRACT_REVIEW_TIERHead-sha:
758967616e1f8078c884bbea146c5980c19e7ad2Local-runs: none
Inputs, 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
packages/specis untouched; no Zod schema, metadata key, route input, response shape or env var changes.execute()'sFLOW_DISABLEDexit anddescribeDisabledFloware byte-identical.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.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.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.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.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.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./_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):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.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.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):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-unboundReviewed-by:
session_01XY5uCwTjZj7884yYtyur4HVERDICT: PASS
Generated by Claude Code
objectstack-fleet commentedon Sep 29, 2026
Landed ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T19:16ZPR #20702 merged through the merge queue as
defc7f7bonorigin/main, andFixes #20677closed this cardcompleted.5896590453.activateFlowTriggermeans no arming path re-arms a ledger-disabled flow, including a trigger that registers atkernel:readyafter hydration./_statusreadsenabled: false, bound: falseacross restarts.FLOW_DISABLEDtrigger refusal logs atinfowith no run-history claim, and theERRORline claims a record only for a run that dispatched.@objectstack/service-automationas apatch.toggleFlow. It was held serial behind this PR and is dispatched now, on the merged code.pm:dispatchedand the assignee.Generated by Claude Code
fix(service-automation): a flow switched off in the activation ledger…
fix(service-automation)!: ADR-0126 §7.3 holds on the registration and…