You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
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
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 reads active: false.
Stop the server process entirely; boot again over the same DB file (OS_LOG_LEVEL=info shows 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_task with priority: 'urgent' (plus the required title, project, status): the server logs ERROR 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 in boundFlowTriggers and calls activateFlowTrigger(name) when its trigger type matches — without consulting isFlowEnabled(name). activateFlowTrigger has no enablement check either.
Trigger plugins register their trigger at kernel:ready, which fires after AutomationServicePlugin.start() pulled the flows and ran hydrateFlowActivations() (which correctly unbound the flow and logged so).
registerFlow does honour the ledger ("A ledger-disabled flow is NOT re-armed here…"); this second arming path does not. The engine pins in flow-activation-ledger.test.ts cover 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.
Path: automation — an administrator's switch-off survives a restart | 缺项 (registerTrigger arms every matching flow without consulting the activation ledger, so a ledger-disabled packaged flow comes back bound:true after a cold boot, and each matching event logs an ERROR claiming a run-history record that is never written) | P1
Triage: 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 ledger
Triage: 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.activateFlowTrigger itself refuses to arm a flow isFlowEnabled answers false for. Then registerFlow, registerTrigger and any later arming path all inherit it. ⛔ Don't patch registerTrigger alone: 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_DISABLED refusal that reaches it anyway (for example, a race) is logged as what it is, ⛔ not as a production failure.
Pins:
A trigger type registered afterhydrateFlowActivations() 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).
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.
Claim: PM loop round 1 · 2026-09-29T18:00Z
Session: session_01XY5uCwTjZj7884yYtyur4H
Account: os-justin (the seat's linked user as GET /user answers 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 grade 5895639312:
One enablement gate in activateFlowTrigger itself, so every arming path inherits it: registerFlow, registerTrigger and any later one. ⛔ Not a registerTrigger-only patch.
A truthful log line: a trigger-fired failure claims a run-history record only when one is written, and a FLOW_DISABLED refusal 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.ts and 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 --tier at 14f80e23: 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:
No open PR touches packages/services/service-automation/ (census of all 11 open PRs at the claim).
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."
]
}
Checklist, read on GitHub rather than from the report:
Shape: a draft onto main, assigned os-justin. Fixes #20677 is line 1 and the only closing keyword, and Clause-②: no is 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 inside activateFlowTrigger, the one point every arming path crosses: registerFlow (its caller-side check folded in), registerTrigger, and toggleFlow's enable branch. registerTrigger's loop carries a ⛔ note against a second check. Not a registerTrigger-only patch.
The truthful line:
a FLOW_DISABLED refusal is logged at info, saying nothing ran and no run-history row records it;
the ERROR line claims "recorded in the flow's run history" only for status: 'failed' (a run that dispatched);
a never-dispatched failure keeps the error without that claim.
This reuses the file's logger. There is no second dialect.
Evidence:
Reached on main: an engine-level boot replay in plugin.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-automation 1924 passed; typecheck exit 0.
60 / 62 derived gates plus the 4 ⛔ rosters exit 0. check:dual-build-cjs-loads and check:type-check-debt are NOT MEASURED locally, lacking a whole-workspace build; CI decides them.
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 / invalid flow 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.
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.
A test comment in api-trigger-secret-registration.test.ts says 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 recordLog throws on execute()'s failure arm, the result still says failed, so the line claims a record while the bookkeeping ERROR says 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 .changeset prose owes. Triage marks this card recommended for the next release.
Inputs, and nothing else: card #20677 (body and all four comments — triage 5895639312, claim 5895758242, os-dev-report 5896376202, ACCEPT 5896413509), PR #20702 (body, file list, the net diff against main from merge-base 14f80e23: 3 files, +403 / −17), and the check-runs on this head. Read-only: git show / git diff against 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/spec is untouched; no Zod schema, metadata key, route input, response shape or env var changes. execute()'s FLOW_DISABLED exit and describeDisabledFlow are byte-identical.
Exports: none moved — right.activateFlowTrigger is private, src/index.ts is untouched, and the new test imports deployment-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 after hydrateFlowActivations() now reads bound: false (was true) — right. That is the card's contract and ADR-0126 §7.2 ("the install-level row may also unbind the trigger"). reason is unchanged because describeUnboundReason already answered undefined for a disabled flow before reading anything else, so getTriggerBindingAudit() 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: activateFlowTrigger has exactly three callers (registerTrigger :3382, registerFlow :4327, toggleFlow's enable branch :4718), and trigger.start( has exactly one call site in engine.ts (:3706), inside it. registerFlow's caller-side if (this.isFlowEnabled(name)) is folded into the gate; equivalent, since flowStatusDisabled is written at :4296 before the re-arm at :4327. isFlowEnabled composes both disable dimensions (ledger and obsolete / invalid status), so the gate inherits both.
Behaviour beyond the card's path: toggleFlow(name, true) on an obsolete / invalid flow, and registerTrigger for a status-disabled flow, no longer arm — right. Before, both produced an armed flow every event of which execute() refused with FLOW_DISABLED and 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 no bound: true for 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_DISABLED reaching the trigger-fired callback is logged at info with 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 a stop() that threw, which deactivateFlowTrigger already reports at warn ("may keep firing until the process restarts") where it happens.
The error line claims "recorded in the flow's run history" only when result.status === 'failed' — right.status is the contract's own discriminator (AutomationResult JSDoc: trigger-time refusals carry no status; the ran-and-failed exit stamps 'failed' after writing its row, as do retryExecution's exits). The call keeps the error(message, undefined, meta) shape — one physical line, envelope in meta third — so the three pins in engine-residual-log-cause.test.ts on this line still hold. One asymmetry, stated by the dev in the PR body: FLOW_INPUT_SCHEMA_INVALID writes one failed row yet carries no status, 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, since describeUnboundReason returns 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-sync registerFlow (:2089); kernel:bootstrapped (:1138) → sealNodeTypeVocabulary() (:1166); trigger plugins register at kernel:ready. It reads no filesystem path (the cross-package-inputs gate is not implicated), covers all four trigger kinds a plugin registers at kernel:ready with 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/** and apps/docs/** are untouched, and at head nothing outside engine.ts, the new test and the CHANGELOG history describes the old line; the CHANGELOG entries (e218483) describe the Trigger-fired run of flow '…' failed lead phrase, which the error line keeps.
② Semver level
.changeset/20677-ledger-disabled-stays-unbound.md declares '@objectstack/service-automation': patch. The package is a released member of the changesets fixed group at 17.5.0; a bug fix in a released package takes patch, never skip-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. patch is 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, the FLOW_DISABLED classification) 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 /_status shape, the ERROR line's quoted claim, the three arming paths, the info level, "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" (describeDisabledFlow untouched) are each true of the code at this head.
Check Changeset on 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.ts is untouched and the replay follows its verified order (①.9). A plugin-level boot needs a new data-engine double under check: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/main moved 3 commits" → answered, accepted.main is now 5 commits past merge-base 14f80e23 (compare API behind_by: 5; local rev-list agrees); none of the five touches packages/services/service-automation/ or packages/spec/src/contracts/ (git log over those paths is empty). No semantic conflict; the queue validates the merge ref.
"Also ran check:durability-log-level and check:startup-registry-verdict" → answered: additive, appropriate to a log-level and arming edit; CI's Lint & Repo Gates decides 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 ("an obsolete flow 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.
recordLog throwing on execute()'s failure arm leaves status: '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.AutomationResult has no write-failed channel; adding one is a packages/spec contract 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).
PR #20702 merged through the merge queue as defc7f7b on origin/main, and Fixes #20677 closed this card completed.
The contract review PASS on the landed head is 5896590453.
What now holds:
One enablement gate in activateFlowTrigger means no arming path re-arms a ledger-disabled flow, including a trigger that registers at kernel:ready after hydration. /_status reads enabled: false, bound: false across restarts.
A FLOW_DISABLED trigger refusal logs at info with no run-history claim, and the ERROR line claims a record only for a run that dispatched.
For the release list: triage marked this card recommended for the next release, and the fix is in @objectstack/service-automation as a patch.
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…