Skip to content

Plan 3: Situation history and Slack ownership - #85

Merged
ernescz merged 31 commits into
state-controllerfrom
feature/state-controller-plan3
Sep 6, 2026
Merged

Plan 3: Situation history and Slack ownership#85
ernescz merged 31 commits into
state-controllerfrom
feature/state-controller-plan3

Conversation

@ernescz

@ernescz ernescz commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Every Situation owns one durable operator history and one Slack root; Slack becomes a projection of that history instead of a per-alert notifier.

  • Immutable Transition ledger per Situation with a folded Episode summary (orientation Observed → Investigating → Monitoring → Recovered / Closed uncertain, attention, checks, outcome), committed atomically with the controller cycle
  • Operator artifacts (annotations, captured verdicts) journal as attributed Transitions in the owning Situation's thread; verdicts can be captured on Acute Triage Findings
  • Publication authority: a Situation reaches the main channel only when the deterministic critical floor or a Sufficient L2 assessment warrants it; warning-only and quiet Situations stay complete in MCP, audit, and stdout with zero Slack intents
  • Slack ownership: one root message per Situation edited in place, material Transitions appended to its thread, broadcast handoffs for operator-facing changes, deadline refresh on the root when the update promise expires
  • Durable notification intents with stable client message ids; older live roots superseded; blocked configuration and operator Slack floor withholding recorded as terminal intent states
  • Delivery gap lifecycle: a gap opens after five minutes of failure, restarts mid-outage produce no duplicates, recovery posts one System notice then replays roots and thread entries in per-Situation order
  • Recurrence milestones from durable occurrences and prior terminal Situations
  • MCP history and delivery state, stdout Situation history, audit events, and OTel span metrics agree on every identity
  • Migrations 0017–0021; public docs updated (architecture, incident memory, scope and limits, configuration, MCP clients, Slack)

Lab acceptance: eight Slack-enabled runs recorded in the private Plan 3 lab-acceptance record (doctopus); the run surfaced and fixed one orientation regression (7694940).

ernescz and others added 30 commits September 5, 2026 23:18
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
Migration 0017 adds Plan 3's history schema: the immutable
situation_transitions ledger, situations' current-Transition
pointer/sequence, the one-row-per-Situation situation_episode_summaries
projection with its monotonic version/source-sequence fence, the
situation_transition_stream stdout outbox, and a create-copy-drop-rename
rebuild of situation_input_outbox adding exact operator-artifact input
provenance (annotation_id/verdict_id) and the R1/R2 journaling cursor
(journal_state, journaled_transition_id). Every Plan 1/2 outbox row,
index, and CHECK is preserved verbatim; no historical Transition is
fabricated for a pre-Plan-3 Situation.

ApplySituationInput now stamps the exact applied_input_version on every
apply, maps both artifact kinds to DueOperatorArtifactRecorded (R5), and
implements R2: an artifact input that reaches an already-terminal owner
is recorded (journal_state='owner_terminal') without joining — no
input_version bump, no due reason, no lease clear — while every other
kind keeps Plan 2's join/create behaviour unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
Task 2's history-upgrade test hardcoded MaxSchemaVersion() == 17, which
was correct only while 0017 was the newest migration. Task 3 legitimately
bumps the global max to 18 (owned by store_test.go's TestMaxSchemaVersion),
so drop the redundant exact-value assertion here — migration 17 landing is
already proven by the schema_migrations row-count check two lines above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
BuildTransitions, ProjectEpisode, PlanNotificationIntents, and the
BuildHistoryCommit composition Task 5 commits atomically.

Two fixes in Task 1's model/history.go were required to land this:

- ProjectionFacts.Validate rejected terminal_at without terminal_reason,
  which made every `recovered` Transition unrepresentable — migration
  0014's own lifecycle CHECK records recovered as terminal_at NOT NULL
  with terminal_reason NULL, and TerminalReason's closed vocabulary only
  describes a closed_unknown closure.
- NotificationIntent.Validate exceeded the repo's gocyclo limit (lint was
  already red on this branch); its bounded-reference and effect-class
  reference checks are now two helpers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
Two review findings on 86b8ea4:

- selectPoke advanced its comparison basis through every Transition in the
  commit, including operator_artifact_recorded ones. An artifact Transition
  copies the commit's NEW lifecycle/Attention/contract verbatim, so the
  controller-state Transition (always last, per R1) was compared against
  itself and every escalation collapsed to PokeNone: an operator annotating
  a Situation in the cycle it escalated to urgent silently lost the poke,
  while the durable Transition still recorded a poke-eligible priority.
  Every Transition is now classified against the pre-commit state.

- Relaxing ProjectionFacts.Validate to accept a bare terminal_at (correct
  for `recovered`) left nothing enforcing closed_unknown => terminal reason.
  A closed_unknown Transition with no reason folded into the Episode summary
  as "Recovered without recorded operator intervention". validateChange now
  mirrors migration 0014's lifecycle CHECK in both directions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
Plan 3 Task 5: Plan 2's fenced CommitController transaction now also
persists this reconciliation's immutable Transitions, one Episode-summary
version folded per Transition, one stdout-stream row per Transition, the
R1 operator-artifact journaling cursor, and every notification intent —
all-or-nothing, so a Situation's authoritative state and its durable
history can never diverge.

- ControllerCommit gains History *HistoryCommit. The controller derives it
  in c.commit, the single choke point all four result classes (reuse,
  fresh L2, fallback, blocked) already funnel through, from a new
  historyBasis{In, Snap, Now} threaded through commitResult/commitBlocked.
  Deriving in one place means no future commit path can silently skip it;
  a derivation failure aborts the cycle before any write.
- AuthoritativeChange.Situation carries the COMMITTED projection plus this
  cycle's CONSUMED due reasons (never the post-commit remainder, which
  would make Triage materiality go dark after the first cycle).
  RecurrenceCount is len(PriorSituations) — the durable same-group
  terminal lineage Plan 2 already loads, not new counting machinery.
- SnapshotInput/LoadReconciliationInput gain the minimum coherent-load
  fields: prior Transition, current Episode summary, Slack root
  publication state, latest root-sync version, last delivered root
  deadline, last main-channel poke, and the ordered pending artifacts.
- The store re-folds the Episode summary from the row read inside the same
  transaction and compares the result to the committed one: migration
  0017's monotonic trigger requires one write per Transition, and
  re-folding also proves the controller derived against current truth.
- A newer pending root projection supersedes the older one in the same
  transaction (migration 0018 allows at most one pending root_sync per
  Situation), with foreign keys deferred for that one self-referencing
  write.
- Bounded coherent read views for Slack/MCP/stdout/replay: current Episode
  with its source Transition, an ordered Transition page on a stable
  (sequence, id) cursor, exact Transition/intent by id, and the pending
  stdout page.

Two changes outside the task's own files were required:

- ProjectEpisode rejected every fold onto a terminal summary, which made
  R1's mandated "artifact pending when the Situation terminalizes" case
  underivable: each Transition of one commit carries the same captured
  projection (R3), so the artifact Transition already reports the closure
  and the terminal Transition could no longer fold. The guard now permits
  exactly the rest of the same terminal commit (identical terminal
  instant) and still rejects any later reopening.
- CommitController's authoritative-attempt insert moved to a helper: the
  one added history call pushed it past the repo's gocyclo limit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
Two review findings on 21a5b30.

- validateFold's "same terminal commit" test compared only
  Projection.TerminalAt, which resolveLifecycle carries forward unchanged
  on every later cycle — it identifies a closure, not a write. A future
  reconciliation of an already-terminal Situation (migration 0017's header
  contemplates one: upgrade_reconstruction for pre-ledger Plan 1/2
  Situations) would have reused the same instant and been let through,
  leaving "a terminal Episode never reopens" enforced only by
  ClaimDueSituations' lifecycle filter rather than by ProjectEpisode
  itself. The exception now also requires t.CreatedAt == prior.UpdatedAt:
  every Transition of one commit is stamped with that reconciliation's
  single Now, and each fold copies it onto the summary, so this holds for
  the rest of this commit and for nothing later.

- supersedePendingRootSyncTx clears the superseded intent's claim fields
  (0018's claim_owner/status CHECK requires it). That consequence lived
  only in the task report, not where the notification worker's implementer
  will read it: a worker can lose a live claim to a concurrent controller
  commit and must re-check status before writing a delivery outcome, since
  a superseded intent can never reach 'delivered'. Documented on the
  function.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
Pure Situation-owned Slack rendering (roots, immutable journal entries,
the installation Delivery-gap notice), a narrow hand-rolled Slack Web
API client (chat.postMessage/chat.update/auth.test) with typed
retryable/configuration-blocking/invalid error classification, and the
concrete deliverer adapter that loads durable state through Task 5's
readers and makes exactly one Slack call per notification intent. No
publication decision and no Store write live in this layer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
A Transition selected as the main-channel poke was getting BOTH a
thread_append and a broadcast_handoff. Migration 0018's uniqueness is
(situation_id, transition_sequence, effect_class), so both were insertable,
and Task 6 renders both classes from the same stored journal data — every
handoff/escalation posted the identical journal entry to Slack twice, where
spec describes exactly one broadcast reply.

PlanNotificationIntents now selects the poke before building the reply loop
and emits exactly one reply per journaled Transition: broadcast_handoff for
the poked one, thread_append for every other, both in Transition-sequence
order across the combined set.

The one deliberate exception stays: a poke below the operator's Slack floor
keeps its withheld broadcast_handoff as a durable decision AND gets a quiet
thread_append, since the floor "never suppresses a non-broadcast journal
entry" — only one of the two is ever delivered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
Task 4's 3ab73d6 stopped emitting a paired thread_append for a DELIVERED
poke, leaving one shape where a Transition still carries two intents: a poke
below the operator's Slack floor keeps a durable withheld broadcast_handoff
AND the quiet thread_append the floor never suppresses.

That shape reaches the claim query, so pin it there: the withheld decision is
never handed out and consumes no attempt, and it never shadows the quiet
entry at the same Transition sequence in its Situation's queue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
…tivate once

Three review findings on the notification delivery worker.

Reactivation deadlocked itself against the pending-root_sync unique index.
It returned every blocked_configuration row to pending in one statement, but
supersession only ever retires a PENDING root_sync, so a Situation can hold a
blocked root beside a newer pending one. Reactivating the blocked one then
violated notification_intents_root_sync_pending_idx, rolled back the whole
configuration-generation advance, and left NOTHING reactivated for any
Situation, permanently. Root projections are now reactivated per Situation:
the newest live projection keeps (or takes) the single pending slot, older
ones are coalesced into it through Task 5's own supersedePendingRootSyncTx,
and when the newest is already pending the older blocked ones stay blocked --
migration 0018 calls a blocked root a resolved outcome, and the pending
projection renders the same current state, so nothing is stranded.

RedriveFailedNotificationIntent shared that hazard and now shares the fix: it
coalesces an older pending root into the redriven one, and refuses with
ErrNewerRootProjectionPending behind a newer one instead of aborting on the
index.

Claim ordering ranked effect class ABOVE Transition sequence, so every
thread_append sorted ahead of every broadcast_handoff and an older poke could
deliver after newer quiet entries -- against spec's 'immutable journal replies
deliver in Transition-sequence order'. Class now enters the order in one
place only (the root projection precedes its replies, which is the only class
ordering the spec requires) and otherwise decides ties within one sequence.

Reactivation fired on any successful Probe. Probe is only auth.test, so a
valid token with a misconfigured channel succeeded forever: reactivate, claim,
channel_not_found, block, reactivate -- unbounded generation and attempt
growth, with first_failure_at reset every cycle so a real five-minute Delivery
gap could never open. It is now once per process, on the startup probe, and
exported as ReactivateConfiguration so Task 9's startup sequence can drive it
explicitly instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
Signed-off-by: ernescz <ernescz@gmail.com>
Review fixes for Task 9:
- alertint_list_situation_transitions clamps limit to its advertised
  maximum, so an oversized limit can no longer return a truncated page
  with next_cursor: null.
- alertint_get_situation lists R2's operator artifacts recorded after
  closure, which no other surface exposed from the Situation.
- The stdout stream's retry backoff keys off the row's durable attempt
  count instead of the Transition sequence.
- Startup step 6's stale-root sweep no longer depends on the boot-time
  Slack probe; only reactivation and gap replay do.

Signed-off-by: ernescz <ernescz@gmail.com>
- internal/situation/history_replay_test.go: real-Store crash-boundary
  replay for Plan 3 history. Reuses Plan 2's replayFixture,
  faultyControllerStore, crash points, and simulateCrash rather than a
  second harness. Eight scripted episodes (first publication,
  investigation, operator artifacts, artifact after closure, recovery /
  refire / recovered, closed_unknown, deadline refresh, recurrence
  lineage + handoff) each run once uninterrupted and once per crash
  boundary on the same logical-clock schedule, then compare a normalized
  Transition/Episode/intent/root/gap projection.
- cmd/alertint/situation_slack_e2e_test.go: deterministic fake-Slack
  end-to-end delivery. Real store, controller, notification worker, and
  SituationDeliverer against a scripted httptest Slack: uncertain post
  with a lost response, failing root edit, rate limit, invalid token,
  channel loss, a six-minute outage, recovery probe, ordered replay, a
  second outage during replay, terminal-before-first-call publication,
  and stale-handoff demotion.
- internal/store: move two #nosec G202 annotations above their
  statements so gosec honors them (comment-only; gosec -quiet ./... was
  failing on the branch head).
- docs: Slack now documents Situation-owned roots and journals,
  orientation, durable intents, at-least-once external delivery,
  indefinite retry, five-minute Delivery gaps, complete replay,
  configuration blocking, the interruption floor and repage cooldown,
  stdout deduplication by transition_id, MCP history, the two System
  exceptions, and no-owner annotation/verdict behaviour. Every claim
  that an Incident notification path still owns Slack is gone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
The `memory` section still said a re-fire edits "the incident's Slack card"
unqualified. No Incident-shaped Slack write is reachable on this branch;
apply the same released-binary / state-controller qualifier already used for
the identical sentence in docs/concepts/architecture.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
Final whole-branch review fixes for Plan 3.

A root_sync withheld by the operator's Slack floor superseded nothing,
so the pending root it replaced stayed claimable forever against a stale
summary version: the Situation never published, every thread_append
behind it was permanently unclaimable, and each failed version check was
recorded as a Slack dependency failure.

- Supersede the pending root_sync whenever any new one is inserted, not
  only a pending one, so the ledger can never hold two live projections.
- Do not withhold a root while an earlier projection is still owed to
  Slack: the floor gates a new interruption, it does not revoke one
  already permitted and merely queued (spec.md's ordinary-delay rule).
  New SnapshotInput/PublicationInput field RootPublicationOwed.
- Add DeliveryLocalRetryable so adapter-internal rejections (stale
  summary version, Store read failure, root not published) retry without
  moving the Delivery-gap machinery. Only a real Slack wire result does.
- Add migration 0019: a partial index for the blocked_configuration
  count the worker runs every second over an append-only ledger.
- Spend the once-per-process configuration reactivation only on an
  actual reactivation, never on a failed read or a no-op probe.
- Correct the docs that claimed an occurrence attach produces a Slack
  recurrence trace; it produces none. Document the `failed` intent state
  and its current recovery limits.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
A Situation that has never earned Slack (no published or owed root) now
creates no notification intent unless its authority Transition carries
publication authority: an unquieted deterministic floor (also proven by
urgent Attention, which validation only ever grants on a floor) or a
validated Sufficient reason. Before this, every material Transition
planned a root and journal intents and the operator's floor was the only
gate, so an observe/no-reason Situation published at the default floor
and a quiet Situation closing with uncertainty published at a medium one
(review round 1, R1-F1). Once a root is on screen or owed, later commits
keep synchronizing it regardless of reason; lifecycle alone grants nothing.

The crash-replay harness and the fake-Slack e2e fixture both relied on
quiet Situations publishing. Both now open warranted Situations through
the one production path a fresh group has — five quiet prior episodes and
a duration_outlier claimed at observe Attention — and the replay harness
arms its delivery crash boundary only once something is claimable, failing
a scenario that never delivers. Under warranted fixtures the harness also
exposed that investigation_concluded's actor depended on whether the
concluding cycle happened to consult the model; it now records llm only
when the conclusion content actually changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V7y36hYk1saPdcd5fr9hg
Signed-off-by: ernescz <ernescz@gmail.com>
… sequence

The Slack deliverer treated a broadcast_handoff as current only when its
Transition was the Episode summary's latest sequence. An attributed
annotation necessarily advances that sequence while changing neither
Attention, lifecycle, nor the required operator action, so a still-required
handoff was demoted to a delayed, "no longer current" thread entry and the
warranted interruption was lost (review round 1, R1-F5).

situation.HandoffStillCurrent now owns the rule: a poke is current while the
Situation is nonterminal, its Attention has not de-escalated, and — for a
handoff that asked the operator for something — the current Operator
contract still asks for the same thing (the same basis a materially changed
required action is judged on). Genuinely changed action, terminal recovery,
and de-escalation still demote stale effects.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V7y36hYk1saPdcd5fr9hg
Signed-off-by: ernescz <ernescz@gmail.com>
…on root

A concurrent controller commit supersedes even a claimed first-root
projection and clears its claim. When Slack had already accepted that post,
its acknowledgement returned ErrNotificationIntentSuperseded before the
returned coordinates were persisted, so the replacement projection found no
root and posted a second one — with no lost response and no crash
(review round 1, R1-F3).

Supersession keeps the claim token. MarkNotificationDelivered now honors a
root_sync acknowledgement whose row was superseded under that exact token:
it records the accepted post as the Situation's root when none exists yet
(never overwriting an existing root — a superseded edit moved nothing), and
still reports the intent as superseded. A stale holder whose lease was
reclaimed carries an older token and writes nothing. The worker's heartbeat
no longer cancels an in-flight delivery on supersession — no other holder
exists, and canceling would manufacture an uncertain outcome — so the
attempt finishes and is acknowledged; the superseded audit event now carries
the delivered coordinates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V7y36hYk1saPdcd5fr9hg
Signed-off-by: ernescz <ernescz@gmail.com>
The claim ranking included only pending rows. Once a handoff's root edit
became blocked_configuration (or failed) it vanished from the per-Situation
ordering, and because the original root coordinates still existed the
handoff was claimable immediately, even though the projection it depends on
never delivered. The same gap let reactivation deliver newer history before
an older blocked reply (review round 1, R1-F2).

Ranking now includes blocked_configuration and failed rows as non-claimable
holders of the queue head: an existing timestamp proves a root exists, not
that this projection reached it, and a blocked reply holds every later
reply. So that an obsolete root projection is never a permanent blocker, a
newer root projection now supersedes every LIVE older one at commit —
pending, blocked, or failed — leaving one live root per Situation; the
obligation continues in the replacement, and a later valid projection
repairs a failed root with no redrive. Migration 0020 replaces 0018's
"supersede from pending only" trigger accordingly (delivered, withheld, and
superseded rows still never become superseded); MaxSchemaVersion is 20.
Reactivation simplifies to "the one live root, if blocked, becomes pending".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V7y36hYk1saPdcd5fr9hg
Signed-off-by: ernescz <ernescz@gmail.com>
Probe is auth.test, yet each successful probe cleared first_failure_at and
could recover an open Delivery gap. With authentication healthy but
chat.postMessage/chat.update failing, the worker reset the continuous
delivery-failure window on every probe, so the mandatory five-minute gap
never opened and a gap could be declared recovered while writes still
failed (review round 1, R1-F4).

Now only an actual delivery success clears the window. A successful probe
still reactivates blocked configuration (once per process) and moves an
open generation into replay — while a gap is open nothing is claimable, so
the probe is the only signal — but recovery no longer touches the window:
the recovery notice's own retries are the write-health probe and gate the
backlog until one lands, no token probe runs while replaying, and until a
write succeeds the anchor keeps naming the same generation, so one outage
never opens a second generation with a second notice. A genuine second
outage during replay begins after that success and gets its own generation.

Two definitions tightened alongside: a definite configuration rejection
(wrong channel, bad token) is durable per-intent state and no longer
anchors the outage window at all, and a generation opens on the FAILURE
that lands at least five minutes into the window rather than on the mere
absence of a success — a single failure followed by five quiet minutes is
an ordinary delay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V7y36hYk1saPdcd5fr9hg
Signed-off-by: ernescz <ernescz@gmail.com>
The controller supplied only the count of prior terminal Situations as the
recurrence count. With one nonterminal Situation per group that number is
fixed for a Situation's whole lifetime, so the milestone comparison could
never fire for re-fires attaching to the current Incident, and
notify.slack.recurrence_mode reached only the retired legacy notifier. The
docs had been corrected to say the setting has no effect, but the spec
still requires preserving recurrence configuration and milestones in the
owning Situation thread (review round 1, R1-F6).

The snapshot now carries each member Incident's recurrence-collapse
occurrence count from the Store; the recurrence count is prior terminal
Situations plus those occurrences, so a re-fire's membership input can
cross a milestone rung and produce the recurrence_milestone Transition
(a quiet thread entry, never a poke, per the existing planner rule).
notify.slack.recurrence_mode reaches the planner through the controller
config: change-gated posts the milestone reply, off keeps only the silent
root edit. Quiet Situations still have no Slack recurrence trace. The
public docs and the drill CLI messages now describe this instead of the
"no effect" claim.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V7y36hYk1saPdcd5fr9hg
Signed-off-by: ernescz <ernescz@gmail.com>
…ew tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V7y36hYk1saPdcd5fr9hg
Signed-off-by: ernescz <ernescz@gmail.com>
HandoffStillCurrent compared the whole Operator contract (minus its
deadline). That tuple includes AlertINT's action, status, wait reason, and
update triggers, which move as Triage progresses or completes while the
required human action is exactly as outstanding — so a queued handoff was
demoted to a delayed "no longer current" thread entry when AlertINT's own
work changed (review round 2, R2-F2).

The revalidation now compares the requested operator action on its own;
terminal recovery, Attention de-escalation, and a withdrawn or changed
human action still demote. PokeRequiredActionChanged keeps its contract
basis (the operator-action catalog has one entry in this build, so a
narrower class would be unreachable); the asymmetry and its interaction
with the repage cooldown are documented on HandoffStillCurrent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V7y36hYk1saPdcd5fr9hg
Signed-off-by: ernescz <ernescz@gmail.com>
…oots on reactivation

R2-F1: both gap-accounting queries treated every pending Situation intent
as replayable. A reply planned under an unpublished, floor-withheld root can
never deliver and was never delayed by the outage, yet it inflated the
recovery notice's affected count and kept CompleteDeliveryGap false
forever. replayableIntentPredicate now defines the backlog as an actual
delivery obligation: a root projection always, a reply only while its
Situation has a published root or a live root projection still owed; the
same predicate drives the notice counts and completion, and the journal
counts again as soon as a later commit earns the Situation a root.

R2-F4: reactivateBlockedRootSyncTx returned early when the newest live root
was already pending, before retiring an older blocked one. A schema-19
ledger can hold exactly that pair (its trigger forbade retiring the blocked
row), and with blocked rows holding the claim queue the old row stalled the
Situation forever — a terminal one gets no controller commit to repair it.
Reactivation now picks the newest root of any delivered or live status,
coalesces every other live projection into it first, then reactivates it
only if it is the blocked one; an old blocked root behind a delivered newer
one is retired rather than revived.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V7y36hYk1saPdcd5fr9hg
Signed-off-by: ernescz <ernescz@gmail.com>
Widening the claim ranking to blocked and failed rows (round 1) left the
poll's predicate unmatched by 0018's pending-only partial index, so every
one-second poll scanned the whole append-only ledger before ranking —
the unbounded growth 0019 was added to stop for the blocked-count read
(review round 2, R2-F3).

Migration 0021 adds a partial index over exactly the three live statuses,
spelled as the poll's own predicate so SQLite's partial-index implication
matches it verbatim; the ranking query is now a named constant and an
EXPLAIN QUERY PLAN test pins that the poll searches
notification_intents_live_idx and never scans the table. MaxSchemaVersion
is 21.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V7y36hYk1saPdcd5fr9hg
Signed-off-by: ernescz <ernescz@gmail.com>
…tion poke

PokeRequiredActionChanged compared the whole Operator contract without its
deadline, which includes AlertINT's work action/status, wait reason, and
update triggers. Once the repage cooldown had elapsed, Triage starting or
finishing produced a new broadcast_handoff for an unchanged
investigate_situation action — a main-channel interruption outside the
spec's closed permitted-poke list (review round 3, R3-F1).

The class now requires the requested human action itself to differ between
the prior Transition and this one, the same basis HandoffStillCurrent uses,
so an internal contract change edits the root and journals its material
entry without ever poking. With this build's one-entry operator-action
catalog the class is reserved rather than reachable; its cooldown gate is
pinned on the helper directly, and the production-derived Triage
start/finish cases are pinned as never poking.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V7y36hYk1saPdcd5fr9hg
Signed-off-by: ernescz <ernescz@gmail.com>
…igated

Lab 2026-09-06 run C (Plan 3 Slack-enabled acceptance): a Situation
published from the deterministic critical floor while L2 was unreachable
carried retry_situation_assessment in its first contract. Because that
contract already names investigation work, no later Transition was ever
classified investigation_started; Acute Triage then ran and
investigation_concluded was journaled, but EpisodeSummary.InvestigationStarted
stayed false and the root's orientation fell from Investigating back to
Observed at the conclusion, contradicting the thread it sat above.

ProjectEpisode now sets InvestigationStarted when it folds an
investigation_concluded Transition — a concluded investigation necessarily
started. A contract that merely names investigation work still does not set
the flag: a clean Triage skip and a direct closed_unknown keep reading as
no investigation having run (existing tests pin both).

Summaries persisted before this change keep their recorded flag; the lab
record notes run C's root as pre-fix evidence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V7y36hYk1saPdcd5fr9hg
Signed-off-by: ernescz <ernescz@gmail.com>
@ernescz
ernescz merged commit 0c75d4c into state-controller Sep 6, 2026
1 check passed
@ernescz
ernescz deleted the feature/state-controller-plan3 branch September 6, 2026 20:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant