Plan 3: Situation history and Slack ownership - #85
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every Situation owns one durable operator history and one Slack root; Slack becomes a projection of that history instead of a per-alert notifier.
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).