Skip to content

fix(service-automation): a flow switched off in the activation ledger stays unbound after a restart, and a trigger-fired refusal logs no run-history claim (#20677) - #20702

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20677-ledger-disabled-stays-unbound
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20677-ledger-disabled-stays-unbound

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20677
Clause-②: no

What was wrong

A packaged flow switched off through the ADR-0126 activation ledger came back armed after a cold restart. At boot AutomationServicePlugin.start() pulls the flows and runs hydrateFlowActivations(), which leaves a switched-off flow unbound. The trigger plugins register later, at kernel:ready, and AutomationEngine.registerTrigger armed every registered, unbound flow of the matching type through activateFlowTrigger without asking whether the flow may run. So GET /api/v1/automation/_status reported enabled: false, bound: true. Every matching event then fired a run that execute() refused with FLOW_DISABLED, and the trigger-fired callback logged it at ERROR as a failed run whose failure "is recorded in the flow's run history". No row was written.

The card's reproduction, reached on current main at the engine level (14f80e23, the pins in commit 1a7ce83b7 run against the unfixed engine): a boot replay over one activation store, in plugin.ts's order (pull, hydrate, kernel:ready protocol sync, trigger registration, seal), reads on the first restart:

AssertionError: restart 1: expected { enabled: false, bound: true } to deeply equal { enabled: false, bound: false }

That is the card's /_status shape. On the unfixed code 10 of the 11 new cases are red, and the one control (a run that dispatched and failed) is green.

What changes (packages/services/service-automation/src/engine.ts only)

  • One enablement gate, in activateFlowTrigger itself. It refuses to arm a flow isFlowEnabled answers false for, before the scheduled-work policy gate and before the trigger lookup. registerFlow, registerTrigger, the enable half of toggleFlow, and any arming path added later inherit it. registerFlow's own caller-side if (this.isFlowEnabled(name)) is folded into the gate, so there is one check, not two. registerTrigger's loop is unchanged; only its comment now names the gate.
  • The trigger-fired line states only what happened.
    • A FLOW_DISABLED answer that still reaches the callback (an event in flight at the switch-off, or a trigger whose stop() failed) is logged at info: the flow is disabled, the run was refused before it started, nothing ran, and no run-history row records it. It is no longer logged at error.
    • Every other failure keeps the existing error line. That line says "the terminal failure is recorded in the flow's run history" only when the result carries status: 'failed', the verdict execute()'s ran-and-failed exit and retryExecution's exit set after writing their failed row. A never-dispatched answer (no status) gets the same line with "it was refused before it dispatched" in place of the history claim.
    • Same logging shape as before, with the envelope in the structured slot. No new log dialect.

The dispatch's mechanism assumptions, measured

  • A1: confirmed. registerTrigger (was about :3368) called activateFlowTrigger(name) for every unbound flow of the matching type. Neither method consulted isFlowEnabled. registerFlow's only ledger check was the caller-side guard around activateFlowTrigger (was :4271). The one at :4257 guards the node-type warning, not arming. The ordering is in plugin.ts: hydrate runs in start(), and the protocol sync and trigger plugins run at kernel:ready.
  • A2: callers of activateFlowTrigger. There are three: registerTrigger, registerFlow and toggleFlow's enable branch. The only refused-flow bookkeeping is policyDisabledFlows (the scheduled-work refusal record). A disabled flow now returns before the policy gate, so it records no policy refusal. describeUnboundReason already answered undefined for a disabled flow, so neither the /_status reason nor getTriggerBindingAudit() changes. /_status's bound reads boundFlowTriggers, which is now false for the flow.
    • One behaviour change beyond the card's path: toggleFlow(name, true) on a flow whose status is obsolete or invalid no longer arms it. Before, it was armed and every event it fired was refused with FLOW_DISABLED (the status dimension) and logged at ERROR, the same defect through the toggle. A status-disabled flow registered before its trigger was re-armed by registerTrigger too. Both are pinned.
    • The enable path for a ledger-disabled flow still arms, and that is pinned as well.
  • A3: when FLOW_DISABLED reaches the log. execute()'s disabled exit returns before a run id is minted and before any recordLog, so no row is ever written for it. Before the fix, it reached the ERROR line on every matching event of a re-armed flow. With the gate, it is reached only by the residue named above.
    • The run-row fact is status: 'failed'. The four status-less failure exits are flow not found, FLOW_DISABLED, FLOW_NO_START_NODE and FLOW_INPUT_SCHEMA_INVALID. The last one does write a failed row but carries no status, so the line makes no history claim for it: true, if less informative.

PR #20551's arm-time api secret refusal is untouched. The gate sits before trigger.start. validateApiTriggerSecret at registration and ApiTrigger.start()'s throw (into the existing Failed-to-bind warn) are unchanged, and api-trigger-secret-registration.test.ts is green.

Tests (all read at 758967616, the last commit)

  • New src/flow-activation-late-trigger.test.ts, 11 cases:
    • A trigger registered after hydrateFlowActivations(), for each of record_change, schedule, time_relative and api: the switched-off flow stays { enabled: false, bound: false }, and its enabled sibling of the same kind arms (the control).
    • The status dimension through the same path.
    • The enable path re-arms a hydrated-disabled flow, and the store row reads active: true.
    • Re-enabling the ledger bit of an obsolete flow does not arm it.
    • A cold restart over one store, twice: enabled: false, bound: false on both boots, the sibling bound, and the binding audit empty.
    • The three log-line cases: FLOW_DISABLED is logged at info with no history claim and listRuns is empty. A dispatched failure is logged at error with the claim and one failed row. A never-dispatched failure is logged at error with no claim and no row.
  • pnpm --filter @objectstack/service-automation test: Test Files 155 passed (155), Tests 1924 passed (1924).
  • pnpm --filter @objectstack/service-automation typecheck: exit 0 (tsc --noEmit, then check:test-typecheck: OK). tsc -p tsconfig.test.json --listFiles lists the new test file (1 hit).
  • Ablation, via scripts/ablation-replace.mjs in wrap mode (anchor must hit once, the restore is armed on EXIT/INT/TERM, and a shell trap git checkout HEAD -- sits behind it). The subject resolves from src/ through the relative ./engine.js import, so no dist/ leg applies.
    • Gate deleted (if (!this.isFlowEnabled(flowName)) return;, anchor x1 then x0, blob 566ed4d67921 then d09694f76678): Tests 8 failed | 3 passed (11). The 8 are every arming pin. For example, restart 1: expected { enabled: false, bound: true } to deeply equal { enabled: false, bound: false }. The 3 log-line cases do not depend on the gate and stay green.
    • FLOW_DISABLED branch disabled: Tests 1 failed | 10 passed. The failing case is the info pin: expected [ Array(1) ] to deeply equal [], where the array holds an error line.
    • History condition forced true: Tests 1 failed | 10 passed. The failing case is the never-dispatched pin, which now claims a run-history row.
    • Every restore proven: ok restored: blob == HEAD (566ed4d67921) and git diff HEAD is empty. git status --porcelain is empty afterwards.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands re-derived against the real diff gives the same 62 commands as the dispatch list, byte-for-byte after sort.
    • Every exit code was captured before any pipe. 60 exit 0.
    • 2 are NOT MEASURED: pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt both exit 3 (PREREQUISITE NOT MET: they read the whole workspace's built dist/, and this worktree built only this package's dependency closure). CI builds that and runs both.
    • --ran: 62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).
  • The roster families printed outside the runnable list all exit 0: node scripts/check-changeset-fixed.mjs, pnpm check:authz-resolver, pnpm check:error-code-casing and pnpm check:filter-alias-parity. Added for the log-level and arming edit, also exit 0: pnpm check:durability-log-level and pnpm check:startup-registry-verdict.
  • Lint, a declared narrowing (pnpm lint over the whole repo is CI's):
    • Run: eslint --no-inline-config --format json on engine.ts and the new test file.
    • Result: 2 files, 0 errors, 0 warnings.
    • The changeset .md has no matching config (--print-config answers undefined, and the JSON says "File ignored because no matching configuration was supplied"), so it is outside eslint's population.
    • eslint.config.mjs never enables type-aware linting (no parserOptions.project or projectService), so this diff cannot move a verdict on any untouched file.
  • pnpm check:nul-bytes: exit 0. A control-byte self-scan of the three changed files finds nothing.

Changeset

.changeset/20677-ledger-disabled-stays-unbound.md is a patch on @objectstack/service-automation. No export, option, route or response shape moves.

Acceptance notes

  • The cold-restart pin is engine-level: it replays plugin.ts's boot order over one shared InMemoryFlowActivationStore. A LiteKernel boot of the real plugin was not built. The plugin attaches the durable ledger only through an objectql data engine with find/insert/update, so it would need a new data-engine double under check:engine-double-contract. The defect lives in the engine's arming path.
  • A test comment in api-trigger-secret-registration.test.ts says "an obsolete flow is re-enabled by a toggle". Under ADR-0126 the toggle moves only the ledger bit, so it never re-enables a status-disabled flow. This is comment drift, outside this card's file surface. Carrier: none.
  • A pre-existing edge, not filed: when recordLog itself throws on execute()'s failure arm, the result still carries status: 'failed'. The trigger-fired line then says the failure is recorded, while the separate ERROR line from that catch says the row never landed. The result has no channel for "the write failed", and the bookkeeping line is itself loud. Carrier: none.
  • origin/main has moved 3 commits since the base (3b47a693c, 0be898499, f379f57f4). None touches packages/services/service-automation or packages/spec/src/contracts, so the branch is not merged forward. CI and the merge queue validate the merge ref.

Generated by Claude Code

…hydration leaves a disabled flow unbound (red on main)

The pins drive every arming path from outside: a trigger type registered
after hydrateFlowActivations(), a cold restart over one activation store,
the enable toggle, and the trigger-fired failure line's run-history claim.
Ten of eleven are red on the unfixed engine; the dispatched-and-failed
control is green.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…so a trigger registered after ledger hydration never arms a disabled flow

activateFlowTrigger now refuses to arm a flow isFlowEnabled answers false
for, so registerFlow, registerTrigger, the enable toggle and any later
arming path inherit it; registerFlow's caller-side check is folded into
it. The trigger-fired callback says a failure is in the run history only
for a run that dispatched and failed (status 'failed'), and a
FLOW_DISABLED refusal that still reaches it is logged at info as a
refusal, not at error as a failed run.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 7 documentable anchor(s).

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via FLOW_DISABLED (literal, a string literal in activateFlowTrigger))
  • content/docs/api/declarative-endpoints.mdx (via FLOW_DISABLED (literal, a string literal in activateFlowTrigger), /api/v1/automation/:name/trigger (route, bridged from symbol toggleFlow — its route source's handler names it))
  • content/docs/api/plugin-endpoints.mdx (via FLOW_DISABLED (literal, a string literal in activateFlowTrigger))
  • content/docs/automation/flows.mdx (via registerFlow (symbol, a method of class AutomationEngine), FLOW_DISABLED (literal, a string literal in activateFlowTrigger), /api/v1/automation/:name/trigger (route, bridged from symbol toggleFlow — its route source's handler names it))
  • content/docs/data-modeling/formulas.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/protocol/kernel/http-protocol.mdx (via FLOW_DISABLED (literal, a string literal in activateFlowTrigger), /api/v1/automation/:name/trigger (route, bridged from symbol toggleFlow — its route source's handler names it))
  • content/docs/ui/actions.mdx (via FLOW_DISABLED (literal, a string literal in activateFlowTrigger))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class), registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-1.mdx (via FLOW_DISABLED (literal, a string literal in activateFlowTrigger))
  • content/docs/releases/v17/17-4.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-5.mdx (via registerFlow (symbol, a method of class AutomationEngine))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0e9ad74fb4e315be2fa6392a6cf478c81dd37ad1 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0c892126198c9266a4f839d1dc042f3dbc91bb13 — the merge of head 758967616e1f8078c884bbea146c5980c19e7ad2 into base 0e9ad74fb4e315be2fa6392a6cf478c81dd37ad1, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0c892126198c9266a4f839d1dc042f3dbc91bb13 && git checkout 0c892126198c9266a4f839d1dc042f3dbc91bb13
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e9ad74fb4e315be2fa6392a6cf478c81dd37ad1 758967616e1f8078c884bbea146c5980c19e7ad2 && git checkout -B drift-repro 0e9ad74fb4e315be2fa6392a6cf478c81dd37ad1 && git merge --no-ff 758967616e1f8078c884bbea146c5980c19e7ad2

node scripts/docs-audit/affected-docs.mjs --json 0e9ad74fb4e315be2fa6392a6cf478c81dd37ad1

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0e9ad74fb4e315be2fa6392a6cf478c81dd37ad1 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 18:54
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit defc7f7 Sep 29, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20677-ledger-disabled-stays-unbound branch September 29, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants