Skip to content

Plan 2: Situation controller and Acute Triage coordination - #84

Merged
ernescz merged 52 commits into
state-controllerfrom
feature/state-controller-plan2
Sep 5, 2026
Merged

Plan 2: Situation controller and Acute Triage coordination#84
ernescz merged 52 commits into
state-controllerfrom
feature/state-controller-plan2

Conversation

@ernescz

@ernescz ernescz commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

One fenced controller commit per Situation cycle; Acute Triage moves onto a durable, bounded schedule under controller decisions.

  • Deterministic Snapshot reduction: stable facts, material fact hash, Assessment basis hash, Incident membership and input digests
  • Assessment validation, revalidated reuse across input versions, deterministic fallback when the model is untrustworthy
  • Bounded L2 dispatch: durable dispatch slots before I/O, one physical request per slot, work attempts per input, park on exhaustion
  • Source-aware lifecycle: recovery grace, observation deadlines, per-Alert symptom reduction so one resolved Alert never recovers a still-firing Incident
  • Acute Triage worker with claim-fenced attempts, clean skips, backoff, exhaustion, and idempotent Finding persistence
  • Installation LLM health with capability records and the shared-primary recovery rule; dependency-parked Situations wake on recovery
  • Retry attach requires the Triage schedule still in backoff at commit
  • Real-store replay fixture with ten crash and restart boundaries; MCP, audit, OTel, and health surfaces agree on consumed slots and attempts
  • Public docs updated (architecture, scope and limits, configuration, MCP clients)

Lab acceptance: seven runs recorded in the private Plan 2 lab-acceptance record (doctopus).

ernescz and others added 30 commits September 2, 2026 01:36
Signed-off-by: ernescz <ernescz@gmail.com>
…shape

Two review findings on the controller contracts:

- Fact.EvidenceRefs, ReasonCandidate.EvidenceRefs,
  SufficientReason.EvidenceRefs, ActionContract.NextUpdateOn,
  Assessment.Limitations, and AssessmentProposal.Limitations were plain nil
  slices that marshaled as JSON null, contradicting spec.md's Assessment
  contract ground truth (next_update_on/limitations always arrays). Added a
  shared canonicalizeSlice helper plus custom MarshalJSON on each affected
  type so a nil-constructed value always serializes [] rather than null.

- Assessment.Validate never cross-checked Cadence against
  a.Lifecycle.Terminal(): a nonterminal Assessment with Cadence("") and a
  terminal Assessment with a non-empty Cadence both silently passed,
  contradicting the Cadence doc comment's claim that "" is the only legal
  value for a terminal Assessment. Added the parallel terminal/nonterminal
  check already done for ActionContract's NextUpdateAt/NextUpdateOn.

Tests: TestNilSlicesCanonicalizeToEmptyJSONArray covers all six fields
(standalone and nested); TestAssessmentCadenceTerminalConsistency covers
both new error cases (terminal + non-empty cadence, nonterminal + empty
cadence).

Signed-off-by: ernescz <ernescz@gmail.com>
Add migrations 0015_situation_controller.sql and
0016_incident_triage_controller.sql: immutable Situation facts, L2
provider call/attempt ledgers, bounded per-Incident Assessment
coverage tuples, the guarded current-Assessment/controller-retry
projection on situations, and the controller-gated incident_triage
rebuild (awaiting_decision phase, Situation/decision metadata, both
coverage digests, fenced lease fields) plus its incident_triage_attempts
ledger.

Also updates the pre-existing v0.13.4 upgrade test: migration 0016's
own awaiting_decision backfill now closes the gap
ListLegacyReadyIncidents used to reconcile, so that legacy staleness
heuristic is correctly superseded by the controller's own B+ gate
judgment.

Signed-off-by: ernescz <ernescz@gmail.com>
…ort/tests

The v0.13.4 upgrade test's doc comment claimed ListLegacyReadyIncidents
is now vacuous/superseded in general. It isn't: reconcileUnscheduledTriage
still calls it every Correlator tick to catch an ongoing, non-transactional
race between MarkIncidentReadyWithSituationInput and SeedIncidentTriage,
unrelated to schema migration. Narrow the comment to what the test
actually proves — migration 0016 closes the one-time, v0.13.4-vintage gap
— and note explicitly that the function remains live production code.

No SQL, schema, or test assertion changes.

Signed-off-by: ernescz <ernescz@gmail.com>
internal/situation (input_worker.go, reconstruct.go) imported
internal/store for SituationClaim, LeaseRecovery, UpgradeIncident,
DeadLetterCounts, ErrNotFound, and ErrSituationLeaseLost. Plan 2's Task 8
needs internal/store to import internal/situation for controller-facing
store methods, which cycles as long as the old direction holds.

Relocate the five plain data structs and the two sentinel errors these
files depend on into internal/situation/model, a leaf package both sides
already import safely. internal/store now aliases its own
SituationInput/SituationClaim/LeaseRecovery/DeadLetterCounts/UpgradeIncident
types and ErrNotFound/ErrSituationLeaseLost values to the relocated
originals, so every existing store.X call site (internal/store itself,
its tests, cmd/alertint's test fakes) keeps compiling unchanged.
internal/situation no longer imports internal/store at all.

Zero behavior change: same field shapes, same error message text, same
error identity for errors.Is comparisons across both packages.

Signed-off-by: ernescz <ernescz@gmail.com>
Implement Task 3 of Plan 2: the Situation controller's fact/dispatch/
outcome persistence layer, interrupted-call recovery, and bounded read
views, plus the forward-declared transport-neutral types (internal/
situation/controller.go, snapshot.go) Task 8's ControllerStore
interface will structurally satisfy against.

- internal/store/situation_controller.go: LoadReconciliationInput (one
  coherent read transaction over Situation/members/deliveries/Triage/
  prior terminals/current Assessment), the idempotent claim-fenced
  AppendSituationFacts and RecordAssessmentCall appends, the
  lease-independent AppendAssessmentOutcome for non-authoritative
  call-backed history, RecoverInterruptedAssessmentCalls (turns an
  outcome-less dispatch under an expired claim into one immutable
  process_interrupted failed attempt and merges retry_due), and
  CommitController's fenced-transaction skeleton (Task 8 completes the
  decision/projection commit).
- internal/store/situation_views.go: GetSituationControllerView — the
  bounded, sanitized current Assessment/contract/hash/due-reason
  projection plus up to 20 recent sanitized attempts and current
  per-Incident Triage state, never raw proposal content or provider
  bodies.
- internal/situation/{controller,snapshot}.go: Claim, AssessmentCall,
  AssessmentAttempt, AuthoritativeAssessment, TriageDecision,
  ControllerCommit, SnapshotInput (+ Delivery/IncidentState/TriageState/
  CompletedSituation). Two fields deviate from plan.md's literal
  Cross-Task Contracts snippet for schema fidelity against migration
  0015 (AssessmentCall.MaterialFactHash/ProviderProfile replace a
  nonexistent AssessmentBasisHash column; AssessmentAttempt.
  UsageInputTokens/UsageOutputTokens replace a single ModelUsage blob,
  and ValidationAdjustments is dropped for having no backing column) —
  documented in both files' doc comments and the Task 3 report.
- internal/situation/input_worker_test.go: mechanical fix, no behavior
  change — references internal/situation/model directly instead of via
  internal/store's now-redundant type aliases.
- internal/situation/reconstruct_test.go: moved to the external
  situation_test package. internal/store now imports internal/situation
  (situation_controller.go) for its controller-facing types; this file's
  own store-backed integration fixtures made it the second (after
  input_worker_test.go) internal test file whose internal/store import
  completed an "import cycle not allowed in test" that only exists for
  test binaries, not production code — Go forbids a package's own
  internal test files from reaching back to a package that imports the
  package under test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXfMEy12bH1TEkN7PoyM9b
Signed-off-by: ernescz <ernescz@gmail.com>
Adds Task 4's pure Snapshot/fact/hash/reason layer over Task 3's
SnapshotInput: DeriveStoreFacts (the 8 Plan 2 fact kinds), BuildSnapshot,
MembershipDigest/IncidentInputDigest, DurationClass, MaterialFactHash,
AssessmentBasisHash, and EligibleReasons. Everything is a deterministic
function over already-provided data — no I/O, no clock reads beyond
SnapshotInput.Now.

Extends TriageState with LatestAttempt (*TriageAttemptResult, nil until
Task 6 wires the store side) so acute_finding has a normalized Finding
source to read, per Task 3's own forward note.

Of the four Sufficient-reason catalog codes Plan 2 can eventually reach,
only duration_outlier is provable from this task's actual inputs today.
critical_anchor, novel_symptom, and terminal_uncertainty are wired into
the catalog but always return false, each documented with the specific
missing data source (severity, persisted symptom history, and lifecycle-
deadline machinery respectively) rather than guessed at.

Signed-off-by: ernescz <ernescz@gmail.com>
Task 4 review found 4 Important findings, all traceable to Delivery
missing data that already exists immutably in alert_deliveries:

- criticalAnchorEligible was always false (no severity signal reached
  the pure layer), so critical_anchor — the only reachable
  deterministic urgent floor in Plan 2 — could never fire.
- MembershipDigest conflated Alert identity with delivery identity,
  churning MaterialFactHash on every routine Alertmanager re-fire of
  an unchanged alert (a new alert_deliveries row, same Alert).
- Reason candidate IDs hashed only code+situation+input version,
  omitting predicate version/typed result/evidence refs, so a
  predicate-version bump silently collided with the old ID.
- MaterialFactHash's Triage contribution dropped output_digest,
  disagreeing with the acute_finding fact about what's material.

Fix: extend loadSituationDeliveriesTx's SELECT/scan to read
alert_deliveries.alert_id and parse labels_json for severity and the
Drill marker; add Delivery.AlertID/Severity/Drill. MembershipDigest
now groups by AlertID and picks each Alert's chronologically-earliest
delivery for FirstDeliveryIDs. IncidentInputDigest's drillParity
placeholder is replaced with real per-Incident Drill data (any-drill-
delivery treated as drill-true). criticalAnchorEligible now checks
firing status + severity.Rank >= 4. Reason candidate IDs are now a
canonical hash over code/catalog version/predicate version/
deterministic floor/sorted evidence refs (plus situation/input version
scoping), mirroring the DTO-then-hash pattern used elsewhere.
materialIncidentDTO now carries TriageOutputDigest; FindingID is
deliberately excluded (storage identity, not decision content).

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

Round-2 review findings on Task 4:

- criticalAnchorEligible latched permanently: it scanned the whole
  delivery history for any firing critical+ delivery, so an Alert that
  had since resolved still confirmed an active critical severity
  forever. Fixed to evaluate, per distinct AlertID, only that Alert's
  latest delivery (by deliveryLess' total order, mirroring
  MembershipDigest's per-Alert grouping but taking the maximum instead
  of the minimum element).

- MaterialFactHash embedded Situation.InputVersion directly, so it
  could never equal itself across two input versions of the same
  Situation even when nothing material changed -- defeating the
  reuse guarantee on every reconciliation, not just routine re-fires.
  InputVersion removed from materialFactHashDTO; SituationID kept.

- materialIncidentDTO embedded the full delivery-level
  IncidentInputDigest, which still churns on every routine
  Alertmanager re-send (a new alert_deliveries row, same AlertID) --
  reintroducing one level up the same class of problem round 1 fixed
  for MembershipDigest itself. Replaced with MembershipDigest, which
  answers the actual "did Incident membership change" question this
  hash needs.

Bumped materialFactHashSchemaVersion, assessmentBasisHashSchemaVersion,
membershipDigestSchemaVersion, and incidentInputDigestSchemaVersion
from 1 to 2 per the file's own stated convention -- the last two were
left unbumped in round 1 despite round 1 also changing their
included-field semantics.

Signed-off-by: ernescz <ernescz@gmail.com>
Implement Task 5 of Plan 2: ValidateAssessmentProposal, DeriveAssessment,
DeriveActionContract, DeriveCadence, RevalidateReuse, DeterministicAssessment,
DeterministicFallback, prompt construction, and typed L2 outcome
classification (ClassifyL2Outcome) — all pure, no I/O, no provider calls.

Also fixes the carried-forward AssessmentBasisHash bug flagged in Task 4's
report: assessmentBasisReasonDTO hashed each eligible ReasonCandidate's own
ID, which bakes in InputVersion (and, transitively, input-version-scoped
fact IDs via EvidenceRefs) via reasonCandidateID. That made the basis hash
change on every input version whenever a reason was eligible, defeating
RevalidateReuse before it could ever fire in the interesting cases. Fixed by
hashing each candidate's stable semantic identity (code, catalog/predicate
version, own deterministic-floor result) instead of its opaque ID; bumped
assessmentBasisHashSchemaVersion to 3 and added regression tests proving
stability across input versions for both a floor (critical_anchor) and a
non-floor (duration_outlier, whose EvidenceRefs are themselves
input-version-scoped) reason.

Signed-off-by: ernescz <ernescz@gmail.com>
…ds, fix reuse freshness check

Three Important review findings on Task 5's Assessment validation/reuse:

- trustworthy() called prior.Assessment.Validate(now) using the CURRENT
  clock, so a prior's stale next_update_at (always true for a later
  reconciliation - the exact case reuse exists to serve) made every
  reuse candidate look untrustworthy forever. Split
  model.ActionContract.validate into a shape-only check and a separate
  requireFreshNextUpdateAt, and added Assessment.ValidateShape (skips
  only the next_update_at-vs-now comparison, keeps every other
  enum/consistency rule). trustworthy() now calls ValidateShape instead
  of Validate(now).

- ValidateAssessmentProposal never checked a proposed Limitation.Code
  against any known set, so a model could fabricate a capability code
  (e.g. "prometheus_confirmed_healthy") and have it stored as
  authoritative. Added knownLimitationCode, checked against
  plan2UnsupportedCapabilities plus the fallback's own
  semantic_assessment_unavailable code.

- forbiddenTopLevelKeys was a 3-key blacklist
  (lifecycle/action_contract/cadence) checked against the raw proposal
  JSON, so a model response that flattened an Operator-contract field
  to the top level (e.g. a bare next_update_at) passed through
  silently. Replaced with an allowlist of AssessmentProposal's actual 8
  legitimate top-level keys.

Covering tests added for all three; full suite green.

Signed-off-by: ernescz <ernescz@gmail.com>
Task 6 of Plan 2: every ready Incident now begins in awaiting_decision
and requires a versioned controller request/skip decision before Acute
Triage may dispatch it, with every decision and attempt fenced by both
membership_digest and incident_input_digest.

- internal/situation/triage.go: DecideTriage, a pure B+ gate decision.
  Skip is legal only when a trustworthy current Assessment's own
  material_fact_hash and per-Incident coverage tuple exactly match the
  current snapshot; a changed membership/Incident-input digest on an
  already-decided pending/backoff row triggers a "refresh" that is
  always request, never a newly discovered skip. Never reads
  Attention/EligibleReasons, so a deterministic urgent floor can never
  shortcut or block the decision.

- internal/store/triage_controller.go: the unexported
  applyTriageDecisionsTx (tested directly; Task 8's CommitController is
  its only future production caller) plus the fenced attempt lifecycle
  over the new incident_triage_attempts ledger --
  ClaimIncidentTriageAttempt (freezes digests and bounded member
  delivery IDs, leases the schedule, appends triage_retry_changed),
  CompleteIncidentTriageAttempt (one atomic/idempotent boundary that
  recomputes current digests and branches between a current-compatible
  Finding and a sanitized stale_membership/stale_incident_input
  record), BackoffIncidentTriageAttempt/ExhaustIncidentTriageAttempt,
  ExtendIncidentTriageLease, RecoverExpiredIncidentTriageAttempts, and
  BackfillUpgradedIncidentTriageSchedule (startup-only, tags retained
  pre-Plan-2 schedulable rows decision_origin=upgrade_existing_schedule
  without retroactively deciding them).

- internal/situation/controller.go, internal/store/situation_controller.go:
  added AuthoritativeAssessment.MaterialFactHash (a genuine Task 3 gap
  DecideTriage's skip condition needs) sourced from the existing
  situation_assessment_attempts.material_fact_hash column.

- internal/store/deliveries.go: MarkIncidentReadyWithSituationInput now
  creates the awaiting_decision schedule row in the same transaction as
  the ready transition and its incident_ready Situation input.

- internal/store/triage.go: SeedIncidentTriage becomes a tolerant
  upsert (promotes a fresh zero-attempt awaiting_decision row straight
  to pending, or inserts fresh if none exists) rather than a bare
  insert, so Correlator's still-unmodified shipped five-attempt
  dispatch path and its full existing test suite keep passing unchanged
  against the new atomic ready+schedule commit. This is a deliberate,
  documented compatibility shim, not a controller decision -- Task
  7/8/9 own replacing it once real dispatch ownership moves off
  Correlator (per the Task 2 report's own note on that boundary).

37 new tests (15 pure DecideTriage, 22 store-level decision/claim/
completion/backoff/exhaust/recovery/upgrade); full repo suite and
internal/store + internal/correlator race tests green; zero changes to
internal/correlator.

Signed-off-by: ernescz <ernescz@gmail.com>
ClaimIncidentTriageAttempt's doc comment claimed it only claims "due"
pending/backoff rows, but the claim query never read or checked
next_at, so a backoff row still an hour from due was fully claimable
right now. Add a next_at read + check after the phase gate, returning
the new ErrTriageNotDue sentinel (distinct from ErrNotFound/
ErrTriageNotDecided) when a row exists and is otherwise claimable but
not yet due.

Also add missing test coverage for ExtendIncidentTriageLease (the
lease heartbeat), which previously had zero tests: a successful extend
pushes lease_expires_at forward without disturbing phase/owner/
attempt identity; a wrong-owner extend, a wrong-attempt-id extend, and
an extend against a row that already moved off in_flight (backed off)
all fail with ErrTriageAttemptLeaseLost.

Fixes 2 Important findings from the Task 6 review
(review-967ad1e..bb5b363.diff).

Signed-off-by: ernescz <ernescz@gmail.com>
Task 7 of Plan 2: separates Acute Analysis (Skill.Analyze) from its
durable dispatch, and moves due-Triage dispatch out of the Correlator
tick entirely into a new internal/situation.TriageWorker that polls
Task 6's gated schedule independently, claim by claim.

- internal/situation/triage_worker.go: the situation-native contract
  (TriageAttemptClaim/AcuteResult/PostCommitData/AcuteAnalyzer per
  plan.md, with one documented field-shape fix -- TriageAttemptClaim's
  MemberIDs/DeliveryIDs collapse into the single MemberDeliveryIDs
  Task 6 actually built) plus TriageWorker itself: claim -> heartbeat
  the lease while Analyze runs -> complete/backoff/exhaust against the
  real result, AfterCommit only on a genuine success (never on a stale
  completion). Deliberately no internal/store import -- store already
  imports situation, so the reverse would cycle -- so
  TriageAttemptStore/TriageScheduleLister are phrased in this
  package's own types; a thin *store.Store adapter for the three
  methods whose store.* types this package can't reference is Task
  9's runtime-wiring job.

- skills/acutetriage/{skill.go,result.go}: extracted analyzeCore (a
  pure extract-method of what pipeline did before persist -- rules ->
  evidence -> LLM -> verification round -> caps; no analysis logic
  changed, only where it lives) shared by Rejudge's unchanged pipeline
  and the new Analyze. Analyze does no durable write and no outward
  call; AfterCommit applies the held role/memory/notify/audit effects
  best-effort, once, only after the store's own completion commits.
  Run is now a compatibility composition (Analyze -> SaveIncidentOutput
  -> AfterCommit) for existing direct/rejudgment tests only -- not used
  by the worker.

- internal/correlator/correlator.go: removed the SeedIncidentTriage +
  dispatchTriage + dispatchDueTriage + recoverTriageState calls.
  MarkIncidentReadyWithSituationInput (atomic since Task 6) is the only
  per-incident triage-adjacent write flushExpired does now -- a
  Correlator tick makes zero model calls (TestFlushExpired_
  MakesNoModelCalls). Deleted triage_retry.go/_test.go (the whole file
  is dead code once its two entry points have no callers); IncidentSink
  stays for API compatibility but nothing invokes it anymore.

Necessary collateral fixes: 7 correlator_test.go tests that observed
readiness via the now-never-called sink, rewritten against a
store.ListRecentIncidents-based wait; export_test.go's two dangling
exports removed; one verify_integration_test.go fixture that built its
reference prompt from a stale (pre-insertTestAlert) Incident value the
new Analyze path correctly no longer trusts.

Four known gaps flagged (not silently patched) in the Task 7 report:
Analyze loads Alert data via the legacy alerts/incident_alerts tables
rather than the claim's frozen delivery ids (the store's completion
digest fence is the real backstop); clean-skip uses
ExhaustIncidentTriageAttempt (no dedicated skip-completion primitive
exists yet, so a clean skip lands the Incident on "failed" rather than
the old schedule's "ready"); failure classification is coarser than
the removed llmhealth-aware classifyTriageError (importing llmhealth
here would cycle through internal/store the same way internal/store
itself would); no equivalent to the old one-hour startup-backlog
horizon.

go build ./..., go vet ./..., and go test ./... (whole repo) all
clean; the three brief-specified -race commands (skills/acutetriage,
internal/situation -run TestTriageWorker, internal/correlator) all
green.

Signed-off-by: ernescz <ernescz@gmail.com>
…store exhaustion signal and error granularity

Fixes 4 Important review findings on Task 7's triage cutover:

- Analyze now loads member content through the frozen
  TriageAttemptClaim.MemberDeliveryIDs (new store.GetAlertDeliveries),
  never GetIncidentAlerts' current mutable projection -- closes the
  re-fire divergence where a claimed Incident's own digests never
  change but the shared alerts row does. Run keeps loading current
  state (it has no claim to freeze against) but now re-reads the
  Incident fresh, matching Analyze's own contract.

- Clean skip now closes through a dedicated store primitive,
  CompleteIncidentTriageAttemptAsCleanSkip: schedule -> skipped,
  Incident -> ready (never failed), one triage_skipped input --
  instead of misusing ExhaustIncidentTriageAttempt, which broke
  later-occurrence collapse and emitted the wrong Situation input kind.

- TriageWorker gained an ExhaustionNotifier hook, called once after a
  genuine 5-attempt exhaustion (never on a clean skip or stale
  completion); skills/acutetriage.Skill.OnTriageExhausted is the
  concrete implementation. cmd/ wiring is left to Task 9.

- classifyAttemptError now checks for an optional ClassifiedError
  interface before falling back to its coarse context.*-only
  classification; skills/acutetriage.classifyAnalyzeError restores the
  deleted classifyTriageError's llmhealth-aware granularity
  (capability-aware codes, schema/malformed-response, LLM-origin
  timeout vs. ambiguous non-LLM shapes).

Signed-off-by: ernescz <ernescz@gmail.com>
…embers

loadFrozenClaimAlerts treated an EMPTY claim.MemberDeliveryIDs as proof of
zero member alerts, but memberDeliveryIDsTx freezes it from
incident_alert_deliveries alone — empty both for a genuinely membership-less
Incident and for one that predates the delivery ledger (pre-migration-0013)
or was reconstructed via UnrepresentedOperationalIncidents/
ReconstructSituation with incident_alerts rows but no
incident_alert_deliveries rows. The latter case was terminally clean-skipped
with a misleading "below the minimum member alert count" reason, even though
the Incident has real members.

Fall back to GetIncidentAlerts (the pre-Task-7 current-state read) when
MemberDeliveryIDs is empty: every current ingestion path inserts
incident_alerts and incident_alert_deliveries together in one transaction,
so a nonempty GetIncidentAlerts result with an empty frozen delivery set can
only be a legacy/pre-ledger row, never a currently-active Incident racing
this claim. Only when GetIncidentAlerts also returns nothing is this
genuinely a zero-member Incident, which still reaches ErrCleanSkip exactly
as before.

Signed-off-by: ernescz <ernescz@gmail.com>
Implement Task 8, the fenced Situation controller: Controller.Reconcile
orchestrates coherent load, fact derivation, Snapshot/hash/reason
reduction, lifecycle timing, pure Triage decisions, deterministic/reuse
checks, work-bearing L2 dispatch with durable before-I/O call recording,
and one fenced CommitController commit, per spec.md's runtime-ownership
order.

- internal/llm: add RequestStartStatus/OneShotCompletion/CompleteOnce to
  both provider clients (zero internal retries, ClassifyRequestStart maps
  true/false/unknown); Complete's existing retry behavior is unchanged.
- internal/situation/lifecycle.go: source-aware recovery-grace and
  lifecycle-observation-deadline derivation (RecoveryGraceDuration,
  ObservationDeadlineAt, ClosedUnknownReason, AnyFiring).
- internal/situation/controller.go: ControllerStore, AssessmentClient,
  AuditSink, ControllerConfig, Controller, NewController, Reconcile, and
  the L2 outcome-matrix dispatch/retry/park machinery.
- internal/situation/controller_worker.go: ControllerWorker (claim,
  concurrent worker pool, global L2 semaphore, lease heartbeat,
  Start/Wake/RunOnce/Drain/Stop, optional dependency-recovery wake hook).
- internal/store/situation_controller.go: complete CommitController's
  fenced commit body, BeginControllerAttempt, LastTrustworthyAssessment,
  ClaimControllerWork/ExtendControllerLease/ReleaseControllerWork, and
  WakeDependencyRecoveredSituations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXfMEy12bH1TEkN7PoyM9b
Signed-off-by: ernescz <ernescz@gmail.com>
golangci-lint pass over the fenced controller: remove dead code
(subtractDueReasons, unused since CommitController's own subtraction is
store-side), fix a duplicated-word error message, make two closed-enum
switches exhaustive instead of relying on default, reorder
dispatchWorkBearing's return values so error is last, and small test
hygiene (t.Helper(), drop an unused param, avoid an unlambda).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXfMEy12bH1TEkN7PoyM9b
Signed-off-by: ernescz <ernescz@gmail.com>
…cy park, add worker backoff, resolve floor/L2 interaction

Two Critical fixes: revalidated-reuse commits now pass the schema's
nominal work_attempt=1 instead of 0 (migration 0015's CHECK rejected 0);
recovery_pending->closed_unknown-via-deadline now carries GraceUntil
forward alongside RecoveryObservedAt (migration 0014's unconditional
pairing CHECK rejected the mismatch). Both were completely hidden by
controller_test.go's fake store, which never enforces either CHECK -
real-SQLite regression + end-to-end tests now cover both call paths.

Three Important fixes: a policy/capability park is now actually enforced
- Reconcile checks it before BeginControllerAttempt/dispatch, skipping
work-bearing L2 entirely while parked against the unchanged basis, and
naturally lifting once the basis changes. ControllerWorker.processOne now
writes a bounded typed backoff (mirroring input_worker.go's own retry
pattern) when releasing a lease after a failed Reconcile, so a
persistently-failing Situation can no longer spin Drain at 100% CPU.
The deterministic urgent floor no longer short-circuits Reconcile before
consulting L2 - the floor's only effect is Task 5's existing
Attention-raising adjustment; a Situation with no prior trustworthy
Assessment goes through the same reuse-check/work-bearing-dispatch path
as any other Situation, so critical_anchor Situations eventually receive
real semantic judgment instead of being stuck on conservative defaults
reused forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXfMEy12bH1TEkN7PoyM9b
Signed-off-by: ernescz <ernescz@gmail.com>
…ot just fresh ones

fallbackOrPreserve's PRESERVE branch called DeriveAssessment directly on a
stale prior Assessment with no floor check and no validateProposalContent
pass, so a critical_anchor floor becoming newly eligible in the same cycle
L2 fails (or the cycle is park-blocked) silently committed the stale
pre-floor Attention instead of urgent. Route the preserved proposal through
the same rebindSufficientReason + validateProposalContent pattern
RevalidateReuse already uses to revalidate a prior against a newer
Snapshot, extracted into a shared rebindSufficientReason helper. On
acceptance the (possibly floor-adjusted) proposal is preserved; on
rejection (e.g. the SufficientReason grounding a stale urgent Attention is
no longer eligible) it falls through to DeterministicFallback instead of
committing a proposal no longer valid against current state. Also corrects
the I3 ruling comment in Reconcile, which incorrectly claimed every
remaining path already guaranteed Attention=urgent when the floor is
active.

Signed-off-by: ernescz <ernescz@gmail.com>
Wire Task 7/8's Situation controller and Acute Triage workers into the
actual running binary: a new controllerRuntime (mirroring foundationRuntime)
composes them with startup recovery/backfill and drain-then-stop shutdown,
main.go builds the one-shot L2 AssessmentClient and wraps it with the
installation LLM-health observer, and the Acute Triage skill drives the
Triage worker directly.

Renames Task 8's ad-hoc controller audit events to spec's exact taxonomy
and adds the four missing Triage-worker events. Closes the one-hour
startup-horizon gap Task 7 deferred (ADR-0045) as a new store primitive.
Extends the Situation controller view and alertint_get_situation with real
Assessment/derivation, hashes, bounded recent attempts, Triage digests, and
retry/park state. Adds the assessment LLM-health capability (migration
0017) and fixes the config/logic cadence-default mismatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXfMEy12bH1TEkN7PoyM9b
Signed-off-by: ernescz <ernescz@gmail.com>
…on, fix runServe lint, thread real duration and request-started into audit

Task 9 review fix round, 4 Important findings:

1. TriageWorker.completeFailure appended incident.triage_exhausted
   directly AND called the configured ExhaustionNotifier (skills/
   acutetriage.Skill.OnTriageExhausted), which appends the same event
   again into the same audit sink in production -> double emission.
   The worker's own direct append now only fires as a fallback when no
   ExhaustionNotifier is configured; the notifier owns the row
   otherwise.

2. The new ExhaustOverdueUnclaimedIncidentTriage startup-horizon sweep
   (internal/store/triage_controller.go) terminally failed Incidents
   with zero audit trail. It now returns per-Incident identifying
   content (ExhaustedTriageIncident) instead of a bare count, and
   controllerRuntime.RecoverAndBackfill emits one incident.triage_exhausted
   row per exhaustion, outside any transaction, with reason
   "startup_retry_window_expired" matching the deleted pre-Plan-2
   applyStartupHorizon's own convention.

3. cmd/alertint/main.go:runServe exceeded golangci-lint's gocyclo
   threshold (32 > 30). Extracted the controller-runtime construction
   (buildControllerRuntime) and two closures with their own branches
   (runFoundationReconstruction, runControllerRecovery) into named
   functions, since Go's gocyclo counts a closure literal's branches
   against its enclosing function.

4. duration_ms was structurally always zero (CreatedAt/CompletedAt set
   from the same clock read) and provider_request_started never
   appeared in any audit payload. buildAuthoritativeAttempt/
   buildOutcomeAttempt now thread the real measured L2 call latency
   (llm.OneShotCompletion.Latency) through to backdate CreatedAt, and
   provider_request_started is now included in every call-backed
   audit payload (assessment_authoritative/reused/fallback, rejected,
   failed, stale).

Tests: TestTriageWorkerAuditsExhaustionOnFifthAttempt now wires a real
audit-emitting notifier and asserts exactly one row (plus a new
fallback-path test); TestExhaustOverdueUnclaimedIncidentTriage* assert
the returned identifying content, TestSituationControllerRuntimeRecoverAndBackfillAuditsStartupHorizonExhaustion
proves a real end-to-end audit row; four new Reconcile-level tests use
a fake client with a controlled non-zero Latency to prove duration_ms/
provider_request_started reflect real measurements, not fixtures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXfMEy12bH1TEkN7PoyM9b
Signed-off-by: ernescz <ernescz@gmail.com>
Round 1's dedupe fix made TriageWorker.completeFailure's own audit
append fire only when no ExhaustionNotifier was configured, on the
theory that the notifier (skills/acutetriage.Skill.OnTriageExhausted)
could build a richer payload instead. That was backwards:
OnTriageExhausted's signature (incidentID, code, detail) never
receives situation_id/attempt_id/attempt_number/input_version, so
every production deployment (which always configures a real
notifier) ended up with the one surviving incident.triage_exhausted
row missing those four identity fields - the only sibling event
(triage_attempt/_stale_input/_completed) not carrying full identity.

Make TriageWorker the single, unconditional owner of the audit row:
it holds the full identity via claim, so it appends the row itself
regardless of whether a notifier is also configured. OnTriageExhausted
no longer audits at all - it is now a pure notification hook (its
real remaining behavior, the notify.TriageFailureSink call, is kept).
This also removes the dependency on a notifier's own choice to audit,
closing the "nil-auditor notifier yields zero rows" fragility flagged
in round 1's review.

Signed-off-by: ernescz <ernescz@gmail.com>
Real-store replay fixture entering through the real Receiver HTTP path
and production foundation/controller/Triage assembly (never a
hand-seeded Situation, fact, Assessment, or Triage envelope), with the
eight crash-boundary subtests the spec names: after Plan 1 input
application; after Situation claim; after L2 dispatch record before
response; after rejected/failed attempt persistence; after
authoritative insert before projection commit; after Triage attempt
begin before result; after Finding persistence before worker return;
and a concurrent input raising a new due reason. Only the L1/L2
clients are deterministic fakes; every store, worker, and pipeline
component around them is real. Each subtest closes and reopens the
same on-disk database file and runs the real startup recovery/replay
sequence before asserting convergence.

The replay went red twice for genuine reasons, not test-infra gaps:

1. AppendSituationFacts (internal/store/situation_controller.go)
   compared a fact's observed_at as part of its idempotent-replay
   conflict check, but DeriveStoreFacts sets observed_at fresh to "now"
   on every call. Any retry of an unchanged input past that point (a
   transient L2 failure, a stale-claim race, or a crash before
   CommitController) always fails closed with ErrImmutableConflict
   forever, permanently wedging the Situation - reachable in ordinary
   operation, not just a crash. Fixed by excluding observed_at from the
   conflict check; the row's first-recorded value stands.

2. incident_triage_state and acute_finding facts (internal/situation/
   facts.go) keyed their identity on (situationID, inputVersion,
   subject) alone, but both read live controller/Triage state that can
   legitimately advance within one unchanged input_version (a
   controller's own request/skip decision landing, or an independent
   Triage attempt completing). Fixed by folding the fact's own content
   digest into its id (factIdentityWithContent) so a genuinely distinct
   observation gets its own row instead of colliding with the prior
   one.

Also updates public docs to say precisely what is wired on
state-controller (durable Situation foundation, the fenced controller,
local Store facts, the B+ Triage gate) versus what is not yet
(connector prep, Assessment/Triage artifacts beyond bounded history,
Transition/Episode history, Situation Slack, the v0.14 cutover),
documents every real situations.* config key against config.go's
current struct, and seeds lab-acceptance.md's evidence-table structure
for the follow-on lab run to fill in.

Signed-off-by: ernescz <ernescz@gmail.com>
…ity and make idempotent-reconverge assertions non-vacuous

Two Important review findings on the crash-boundary replay suite (72bcde8):

1. factIdentityWithContent (Bug 2's fix) had zero test coverage anywhere.
   Added two direct unit tests in facts_test.go pinning that a different
   Phase/LatestAttempt at the same (situationID, inputVersion, subject)
   produces a different fact ID — verified red on a reverted fix, green on
   current. Also added a systemic assertNoReconcileFailed check inside the
   replay fixture's own convergence helpers (after every controller drain
   round): asserts no Situation ever carries a non-nil last_error_class,
   catching any reconcile cycle that silently failed and got swallowed by
   ControllerWorker.processOne — not just this one bug, but the whole class.

2. assertIdempotentReconverge (and boundary 6's own re-run) proved nothing:
   every re-run's clock advance (1 minute) never reached the committed
   slow-cadence next_assessment_at checkpoint (+15m), so zero controller
   work ever executed and "no new L2 call"/"hashes unchanged" held
   vacuously. Fixed by aging each tested Situation into DurationClassLong
   (unbounded above, so no later class-boundary crossing can force a fresh
   L2-calling derivation) before its first tested convergence, then
   advancing past the checkpoint before reconverging, and asserting the
   controller genuinely claimed the Situation again. This also corrected an
   over-assertion: current_assessment_id was required to stay byte-
   identical across a reuse commit, but a reuse commit always mints its own
   new authoritative attempt row (already pinned by
   TestControllerReconcileEndToEndReuseCommitsAgainstRealSchema) — that
   requirement was never exercised before this fix and turned out to
   contradict already-established production behavior the moment a real
   second cycle ran. Replaced with a hash-stability + fresh-attempt +
   derivation=revalidated_reuse check.

Fixing #1's systemic assertion surfaced a genuine, previously-undetected
production bug (not caused by, or specific to, either fix above): a crash
landing after RecordAssessmentCall durably records a Situation's first-ever
L2 dispatch but before that cycle's own CommitController ever runs leaves
current_material_fact_hash unset, so the next reconcile's
BeginControllerAttempt resets work_attempt to 1 again and collides with the
immutable pre-crash situation_assessment_calls row on its own
UNIQUE(situation_id, input_version, retry_epoch, work_attempt, call_number)
index — silently wasting one cycle on a degraded deterministic_fallback
Assessment before self-healing on the next cycle. Reproduces boundaries 3,
4, and 5 identically with the clock-advancement fix removed, so it is not a
side effect of either fix; documented at length in the test file and left
red (not weakened or scoped away) pending a dedicated production fix,
per this task's own explicit instruction not to paper over a genuine
discovery.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXfMEy12bH1TEkN7PoyM9b
Signed-off-by: ernescz <ernescz@gmail.com>
…rrupted-call recovery

A crash landing after RecordAssessmentCall's durable dispatch record but
before that cycle's own CommitController commit left
situations.controller_work_attempts/current_material_fact_hash behind the
immutable pre-crash situation_assessment_calls row. The next real
BeginControllerAttempt call then recomputed the EXACT SAME
(retry_epoch, work_attempt, call_number) coordinates, colliding on that
table's own UNIQUE index and silently degrading to a deterministic_fallback
commit for a whole cycle (boundaries 3/4/5 of TestControllerRealStoreReplay).

RecoverInterruptedAssessmentCalls now also catches up each touched
Situation's controller basis:

- loadStaleControllerBasisTx finds every un-claimed Situation whose
  projection has fallen behind its own most-recently-dispatched call —
  deliberately broader than "outcome-less": a call can already have a real
  rejected/failed outcome (AppendAssessmentOutcome's own separate commit)
  while its owning cycle's CommitController still never ran.
- advanceControllerBasisForRecoveryTx writes current_material_fact_hash/
  controller_work_attempts to match that call (so an unchanged basis
  correctly continues at work_attempt+1), unconditionally bumps
  controller_retry_epoch (so a basis that has since drifted — e.g. a
  duration-class boundary crossed during the crash-to-restart gap — still
  lands on a fresh, never-colliding coordinate lane, mirroring the existing
  dependency-recovery wake primitive), and clears any controller_parked_at/
  controller_parked_reason (provably always stale whenever an interrupted
  call exists, since controllerParkBlocksDispatch would otherwise have
  prevented that call from ever being dispatched).

Regression coverage: TestRecoverInterruptedAssessmentCallAdvancesBasisSoNextAttemptDoesNotCollide,
TestRecoverInterruptedAssessmentCallsCatchUpStaleBasisAfterResolvedRejectedOutcome,
TestRecoverInterruptedAssessmentCallClearsStalePolicyParkOnBasisAdvance
(internal/store/situation_controller_test.go). All 8
TestControllerRealStoreReplay subtests now pass; shrank the resolved
root-cause comment above boundary 3 to a short pointer at this commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXfMEy12bH1TEkN7PoyM9b
Signed-off-by: ernescz <ernescz@gmail.com>
…close crash-free epoch gap

Fix round 3 on internal/store/situation_controller.go (Task 10 Part 1).

C1 (Critical regression from 2e6fa67): loadStaleControllerBasisTx's
"controller_work_attempts < c.work_attempt" disjunct matched a Situation
freshly reset by wakeOneDependencyRecoveredSituationTx (work_attempts=0,
same basis) as if it were a crash gap, and advanceControllerBasisForRecoveryTx
restored work_attempts back up to the old exhausted call's value -
silently, permanently re-stranding a woken Situation. Removed the disjunct;
the query now matches only current_material_fact_hash NULL-or-mismatch,
which is both necessary and sufficient. The now-redundant
MAX(controller_work_attempts, ?) write became a plain assignment,
since work_attempts is always already caught up under the narrowed query.

I1 (original bug's crash-free variant): BeginControllerAttempt now advances
controller_retry_epoch by 1, in the same transaction as work_attempts, when
a Situation transitions from one previously-committed basis to a genuinely
different one (never on a first-ever dispatch) - closing the same
UNIQUE-index collision 2e6fa67 fixed for crashes, but reachable with no
crash at all when MaterialFactHash drifts between two ordinary reconcile
cycles at the same input_version.

Two new regression tests added; all pre-existing assertions unchanged.

Signed-off-by: ernescz <ernescz@gmail.com>
Mechanical lint pass, no behavior change: named interface parameters,
a fixed audit actor constant instead of a repeated literal, #nosec
annotations with rationale on the bounded shift/jitter in retryBackoff,
thelper/unparam annotations on test fixtures, the multi-value fake
snapshot() replaced by a struct, an unused test helper removed, and the
nested fallback-or-preserve branch split into a guard-clause helper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
A Situation whose controller lease is lost mid-cycle with no restart
(heartbeat ExtendControllerLease error cancelling the reconcile, a stall
outliving the lease, or a CommitController error) leaves a durable
situation_assessment_calls row whose cycle never committed. The
projection cache on situations then still describes the previous commit,
so the next claimant re-minted the same (retry_epoch, work_attempt,
call_number) and collided on the UNIQUE index; RecoverInterruptedAssessmentCalls
is startup-only and never ran. BeginControllerAttempt now reconciles
against this input version's most recently dispatched call inside its
own fenced transaction: if the projection hash is NULL or differs from
that call's basis, it repairs the projection to what that commit would
have written (hash, work_attempt, park cleared) before choosing a
coordinate. The startup pass's basis catch-up is defense in depth.

Tests drive the exact worker-A-dispatch / lease-expired / worker-B-claim
sequence against the real schema for five stranded shapes, the stale
policy park, the older input version, and the woken dependency park, and
pin the three crash-recovery probes that used to live in a scratch file.
The lab-acceptance note records the accepted edge (a stranded fifth
attempt leaves the basis exhausted with no park marker).

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

Eight findings from the branch review, each fixed at the seam that owns
it:

- LLM health observed the transport, not the outcome: a parseable but
  malformed or policy-invalid Assessment proposal was recorded as a
  healthy call, and every observation used an empty subject so the
  two-Situation corroboration rule could never fire. The controller now
  exposes AssessmentHealthObserver and reports each dispatch's final
  ClassifyL2Outcome with the Situation ID; cmd/alertint maps it onto the
  llmhealth tracker (content vs dependency class, stale = success). The
  CompleteOnce decorator is gone.
- A post-claim clean skip consumed a Triage attempt, contradicting the
  Part 2 invariant. The Triage worker now resolves minimum-member
  eligibility BEFORE claiming, through a store primitive that counts
  members and closes the schedule as skipped in one transaction with no
  attempt row and no attempt increment; the skill exposes
  MinimumMemberAlerts. Analyze's ErrCleanSkip path stays as defense in
  depth.
- Shutdown order matches the plan: Receivers, Correlator (so its
  fixed-window ticker cannot mint fresh durable work after the drain),
  then foundation and controller/Triage drains in rounds until a round
  handles nothing, then the workers.
- Cadence is live configuration: ControllerConfig.Cadence reaches the
  contract derivation through ControllerState.Tempo, defaults are the
  plan's 60/300/900s, and the loader enforces fast < normal < slow.
- MCP exposes the eligible Sufficient-reason candidate set: the commit
  persists it on situations.current_eligible_reasons_json, the Store view
  reads it, alertint_get_situation renders eligible_reasons.
- Migration ownership: the llm_health_capabilities widening that shipped
  as 0017 is folded into unapplied 0015; MaxSchemaVersion is 16 again and
  0017/0018 stay free for Plan 3.
- The Correlator is constructed with NopIncidentSink; the Skill-backed
  sink and its callback are removed, with a wiring test.
- OpenTelemetry spans for the controller cycle, each consumed L2 dispatch
  slot, and each consumed Triage attempt carry stable identity, digest,
  count, result-class, and duration attributes and never payloads,
  pinned by a span-recorder test. Spans go through the global provider;
  no exporter is wired in this build, and the docs and lab-acceptance
  notes say so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
ernescz and others added 22 commits September 4, 2026 09:50
SeedIncidentTriage was a production Store method with no production
caller — every ready Incident begins awaiting_decision and only a fenced
controller decision moves it — and a doc comment describing the pre-Plan-2
Correlator dispatch chain. It now lives in internal/store/storetest as a
test-only fixture over *sql.DB (so the store's own in-package tests can use
it without an import cycle), the two stale comments naming the removed
dispatch chain are reworded, and the repo-side lab-acceptance structure
points at the canonical private evidence record. Task 10's forbidden-
leftover sweep now returns only the documented absence of max_l1_llm_calls.

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

The Skill-backed IncidentSink was already gone, but production still
handed the Acute Triage skill to cor.SetRejudger, and attachOccurrence
still called Rejudge on an escalation trigger. Dormant on the durable
Delivery path or not, that is an analyzer/LLM dispatch dependency the
Correlator must not carry (Plan 2 Task 7).

- Delete Rejudger, the rejudger field, SetRejudger, and the Rejudge call;
  an escalation now only stamps its trigger_kind on the occurrence row
  (actionRejudge renamed actionEscalate to say what it does).
- cmd/alertint no longer passes the skill to the Correlator.
- The wiring proof now also checks the Correlator type for any re-judgment
  seam and that no non-test correlator source imports internal/llm,
  internal/llmhealth, or skills/*.
- Correlator tests assert the durable trigger instead of a fake rejudger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
…bort when it cannot be

Two review findings on the controller's L2 dispatch loop:

- A failed AppendAssessmentOutcome was logged and the cycle went on to
  audit, correct, and commit as if the row existed. dispatchWorkBearing
  now returns its own infrastructure error, distinct from the provider
  transportErr the caller classifies; reconcile aborts on it, and the
  assessment_rejected/_failed audit event is emitted only after the
  outcome row committed. Prompt-build and call-record failures take the
  same path instead of being classified as transport outcomes.
- A cancellation while a call was still waiting for the worker's L2
  semaphore returned a zero-valued completion, so the outcome row carried
  provider_request_started "" (rejected by the store CHECK) on a context
  that was already dead. The semaphore now reports RequestStartStatusFalse
  (no request was attempted), and the outcome row plus its audit event are
  written on a short detached context once the cycle's own context is
  done, mirroring the Triage worker's detachedWriteContext — a real-store
  test drives two due Situations through one slot and reads the 'false'
  row back.

Every span site now also writes one structured log line with the same
identities and the span's trace_id/span_id (reconcile, dispatch slot,
Triage attempt), pinned by tests against a span recorder, so logs, spans,
audit, and the store reconcile by identity.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
…ller and Triage spans

Plan 2's lab gate reads consumed L2 dispatch slots and Triage attempts
from OTel next to MCP, audit, SQLite, and logs, and the lab runs an
OpenTelemetry Collector — but the binary installed no exporter, so the
spans never left the process.

- New config section telemetry.otlp (enabled, endpoint, protocol grpc|http,
  insecure, service_name, timeout_seconds); off by default, validated
  under the strict loader, documented in the configuration reference and
  config.example.yaml.
- New internal/telemetry: builds the OTLP exporter and a batching tracer
  provider (resource from config + OTEL_RESOURCE_ATTRIBUTES), installs it
  as the global, and returns a flush/shutdown; never dials eagerly, so an
  unreachable collector is a logged export error, not a startup failure.
- cmd/alertint starts it after config load and flushes it after the
  foundation stop sequence; the receivers stop step moved into
  receiverShutdown to keep runServe within the complexity gate.
- lab-acceptance.md: check 10's OTel columns are filled from the agent's
  per-cycle log lines (trace/span IDs) and the collector-derived
  span-metrics series in the lab Prometheus, reconciled against the
  SQLite ledgers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
…on codes in the L2 prompt

First live lab run of Plan 2 (2026-09-04): every L2 call was rejected as
invalid_shape and the controller burned all five work attempts into
deterministic fallbacks. Reproduced against the same model from the lab:
the schema instructions showed sufficient_reason as null and limitations
as [], and the model answered with a bare candidate-ID string and
free-text strings. The validator's closed shapes were never stated.

The prompt now spells out sufficient_reason as {code, candidate_id,
summary, evidence_refs} copied verbatim from eligible_reasons, limitations
as {code, detail} objects, the bounded text length, and the allowed
limitation codes rendered from the same list the validator checks. Two
live calls with the new prompt produced the documented shape.

Tests pin the nested-shape wording, every allowed code, the observed lab
shape as an invalid_shape regression, and the documented shape passing the
shape and capability gates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
…n the Situation view

The 2026-09-04 lab acceptance run (check 10) caught the MCP Situation read
disagreeing with SQLite, audit, logs and OTel on "consumed Triage attempts":
listIncidentTriageViews projected the count and phase from the
incident_triage schedule row, which CompleteIncidentTriage deletes once a
Finding is persisted, so a just-judged Incident rendered triage_attempts 0
and an empty phase while incident_triage_attempts held the consumed attempt.

The view now reads attempts as MAX(schedule counter, ledger row count) and
renders phase "completed" when the schedule row is gone and a successful
attempt exists. Two store tests pin the post-completion state and the
never-below-ledger rule.

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

The lab step ran on 2026-09-04 (two fresh-database runs, Slack disabled).
The repo copy stays a structure without IDs; its status now says which
checks passed on live evidence, which are pending, and what the two runs
found (prompt shapes, MCP Triage-attempt projection).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
…ass, one controller defect open

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
A work attempt whose durable slot was reserved but whose cycle never
committed (the process died mid-dispatch, e.g. a drain-time L2 call cut
off by SIGKILL) left the Situation exhausted with no park recorded. The
stranded repair skipped it (unchanged material hash), every cycle
re-entered the exhausted branch and re-committed the bounded fallback on
cadence with a healthy LLM, and WakeDependencyRecoveredSituations could
never re-arm it because it only selects dependency parks. Only a material
hash change (a duration-class boundary) ever recovered it; in the long
class it never would.

Reconcile's ErrControllerAttemptsExhausted branch now records a
ParkedReasonDependency park when the loaded input carries no park, so the
next dependency recovery generation opens one new epoch. A park already
on record is left untouched, as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
…path, and the reuse hot loop

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
…ckpoint (lab F8)

A Situation whose lifecycle-observation deadline had already passed but
whose alert kept firing stayed active, as it must, yet the past deadline
was still folded into next_update_at as a "reconsider at" candidate.
nextUpdateAt clamps an already-due candidate to now + 1 s, so the worker
re-claimed the Situation on every 2 s poll for as long as the alert fired:
one revalidated_reuse attempt row and one audit row every two seconds,
without end (lab run 4: a week-old ContainerMemoryHigh alert, ~30 rows a
minute).

buildControllerState now carries the deadline only while it is still
ahead of now. The lifecycle already reconsiders a passed deadline every
cycle in resolveLifecycle, so nothing is lost: closure still happens the
moment firing truth is gone, and a firing Situation idles at its cadence
like any other. A still-future deadline is promised and scheduled exactly
as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
…e lab acceptance note

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
…ified live, one design gap open

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
… others' dependency failures (lab F10)

The dependency-recovery wake waits for the rolled-up LLM state to be
healthy, and the assessment capability could heal only on an assessment
success. While every Situation is parked on that very outage no
assessment can run, so in the lab the wake waited five minutes for an
unrelated lease to expire instead of following the first Triage draft
that proved the provider was back.

triage_draft, assessment, verification_rejudge and query_repair are
served by the one configured primary client and model, so a real success
on any of them now withdraws a dependency-class failure on the others and
on the probe. Content-class failures stay with their own capability, the
memory classifier is outside the rule in both directions, a probe success
still clears nothing but the probe, and a capability that was not called
keeps its own last_success_at (the old probe clearing fabricated one).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
…mmit (lab F5)

The correlator plans a retry attach after reading that the Incident's
Acute Triage schedule is in backoff, but the commit re-checked only
Incident failure and terminal Situation ownership. A clean skip committed
in between is a first judgment that closes membership, so a later alert
could attach behind it without any decision seeing it.

The attach now carries the exact precondition it was planned under: the
schedule row must still be in backoff inside the transaction (a missing
row counts as not in backoff). Otherwise the plan is rejected with the
existing not-collapsible sentinel and the correlator re-plans the
delivery through its next attachment path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
…ace fix in the lab acceptance note

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

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

deriveSymptoms took the status of the Incident's most recently received
delivery, so one Alert resolving while a sibling still fired read as a
resolved Incident and drove active -> recovery_pending -> recovered under
a still-firing Alert (external review of the finished branch, P1). The
rationale in Symptom's doc comment — no Alert identity reaches the pure
layer — had been stale since Delivery.AlertID landed with the Task 4
review fixes; criticalAnchorEligible already reduced per Alert.

Reduce per Alert: each distinct AlertID contributes only its
chronologically latest delivery (deliveryLess, the same order
MembershipDigest and criticalAnchorEligible use) and the Incident is
firing while any Alert's latest delivery is firing. Key stays the
Incident ID; FirstObservedAt is unchanged. MaterialFactHash and the
symptom facts change only for mixed-status Incidents, which were exactly
the wrong cases.

Tests: the same-Alert resolution case keeps its resolved reading; new
cases for a still-firing sibling, all-Alerts-resolved, a re-fire after a
resolution, and two Reconcile-level cases (partial resolution stays
active across cycles; full resolution reaches recovery_pending).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
…son-predicate scope revision

External review of the finished branch raised two plan-versus-code
discrepancies. Both resolve toward the spec, with the plan amended.

Replay boundary 9: a crash strictly after CommitController's transaction
committed but before Reconcile observed the result. The verbatim replay
of the same commit against its now-stale claim fails closed with
ErrSituationLeaseLost and changes nothing (spec: the commit lands only
while owner and claim token still match); convergence comes from the
next claim, which finds the committed Assessment and projects it
without redispatch. The store-level test's doc comment now says why
fail-closed is the intended contract.

novel_symptom and terminal_uncertainty stay unreachable, now for the
right reasons: novel_symptom needs persisted per-Situation symptom
identity and a per-Alert symptom key that Plan 2 does not have;
terminal_uncertainty has its deadline half (ObservationDeadlineAt) but
no definition of the spec's "actionable uncertainty" conjunct, and the
old comment claiming the deadline did not exist was stale.

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

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

Boundary 9's companion, from the external review's follow-up: age the
duration class before the pre-crash commit and leave it alone after the
restart, so the basis is unchanged and the proof is direct — startup
replay plus one converge pass dispatch no L2 call, grow no dispatch rows,
and produce exactly one revalidated_reuse Assessment bound to the new
claim with the same hashes. Boundary 9 ages the class after the restart,
which forces a fresh derivation and cannot prove this half on its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfKEvYSqoFFmL81L1Dsexs
Signed-off-by: ernescz <ernescz@gmail.com>
@ernescz
ernescz merged commit 082a99a into state-controller Sep 5, 2026
1 check passed
@ernescz
ernescz deleted the feature/state-controller-plan2 branch September 5, 2026 08:24
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