Skip to content

Plan 4: Bounded evidence preparation and semantic profiles - #86

Draft
ernescz wants to merge 13 commits into
state-controllerfrom
feature/state-controller-plan4
Draft

Plan 4: Bounded evidence preparation and semantic profiles#86
ernescz wants to merge 13 commits into
state-controllerfrom
feature/state-controller-plan4

Conversation

@ernescz

@ernescz ernescz commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

WIP: Bounded evidence gathering and advisory semantic profiles for the Situation controller.

Implementation and validation are ongoing. Not ready to merge.

ernescz and others added 12 commits September 7, 2026 01:26
Freeze the transport-neutral shapes Plan 4 builds on:
internal/observation/model (Fence, closed Capability/Phase/ResultStatus/
Freshness vocabularies, Plan/Cycle/Run/Fact shapes, CanonicalPlanID,
MaterialDigest, ValidatePlan/ValidateRun) and internal/semanticprofile
(advisory Signature/Profile/Version/JobClaim model, BuildSignature's
proven-id/proven-id-only/fallback precedence, ParseProfile's
duplicate-key-rejecting + strict-decode + bounds pipeline). Add the six
situations.preparation/semantic_profiles config defaults, their range
validation, and reference documentation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZWdCMiwFv57w2auUMWXUi
Signed-off-by: ernescz <ernescz@gmail.com>
Add migration 0022 and internal/store/situation_preparation.go implementing
the six Task 2 store APIs: BeginPreparation (freeze-or-reuse a cycle keyed by
situation/input-version/generation, sealing-driven generation counter),
ReserveObservationRequest (cycle+plan physical-request budget enforcement,
investigation-credit debit for the protected optional plan), CompleteObservationRequest
(fence-free durable outcome recording), CommitObservationRun (idempotent replay,
conflict detection, reference-lifecycle bookkeeping), ListObservationRuns
(cursor pagination, archive-safe detail_state), and PruneUnusedObservationDetails
(the ten-day unused-detail retention sweep, ADR-0051). Wire cycle sealing into
CommitController's existing fenced transaction via sealPreparationCycleTx, and
add the two new ControllerCommit fields it reads.

Found and fixed along the way: a SetMaxOpenConns(1) self-deadlock from querying
while an outer rows cursor was open in ListObservationRuns, and a reference-
lifecycle gap where a run's open_cycle protection never lifted (blocking
retention indefinitely) -- sealing now transitions it to a current_cycle
reference, superseded only when a later cycle begins.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZWdCMiwFv57w2auUMWXUi
Signed-off-by: ernescz <ernescz@gmail.com>
Add internal/observation's deterministic planner (BuildPlans), the
investigative-fairness budget allocator (allocateFairly: time-sensitive
lifecycle work first without limit, one protected optional plan admitted only
when both remaining cycle capacity and durable credit cover its full
MaxRequests, routine lifecycle filling what remains, least-recently-served
first within each tier), and the Runner that executes a frozen cycle's plans
through registered capability executors -- reusing any plan with an existing
committed run, committing a vocabulary_unresolved run for a capability with no
registered executor (no I/O), and a failed run for an executor error, so one
plan's failure never blocks the rest of the phase.

Add the store_read capability's executor (internal/observation/connectors):
bounded prior-Situation summaries and durable findings from AlertINT's own
local store, never spending the RequestRecorder's physical-request budget
(a local SQL read, not a physical network slot). Add the corresponding
LocalSituationSummary/LocalFinding/LocalChange model DTOs (LocalChange is
change_events' own local-read shape, added here so internal/store's two new
bounded local-read methods share one leaf dependency).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZWdCMiwFv57w2auUMWXUi
Signed-off-by: ernescz <ernescz@gmail.com>
Add the Task 4 connectors and their bounded upstream client APIs:

- prometheus.Client.QueryRangeBounded (server-side series cap on range
  queries, mirroring QueryInstant's existing limit param).
- loki.Client.FetchRecentBounded (existing selector/label-map translation
  and filtered+fallback logic, with each physical request -- including the
  fallback -- individually reserved/recorded rather than hidden behind one
  apparent call).
- sentry.Client.doGETInstrumented + ListIssuesBounded (per-physical-attempt
  instrumentation covering 429/5xx retries, plus Link-header pagination
  metadata ListIssues previously discarded).
- store.ChangesInScopeWindow (a new scoped, limited query against the local
  change ledger -- never the unbounded, installation-wide ChangesInWindow).

Each observation.Executor builds a deterministic query from the frozen
plan's scope, persists bounded normalized summaries (never a raw matrix,
full log dump, or raw exception text unless IncludeMessage is configured),
and reports the closed nine-value result vocabulary including truncation
and withheld-by-budget. store_read and change_events never spend the
RequestRecorder's physical-request budget (local reads); the other three
account for every physical dispatch individually.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZWdCMiwFv57w2auUMWXUi
Signed-off-by: ernescz <ernescz@gmail.com>
Add internal/zabbix/problem_history.go (ProblemHistory, EventLifecycle)
using Zabbix 7.0's event.get for historical resolved events (problem.get
only retains unresolved/recently resolved problems): exact host/trigger
event timeline, paired recovery event times via a batched secondary
lookup, ongoing/unknown state, severity/acknowledgement/suppression, tags,
and cause-event id. A failing or budget-withheld recovery lookup degrades
the affected episodes to RecoveryUnknown rather than losing the already-
fetched primary evidence. Add callInstrumented/MetricHistoryBounded so
every physical request this package makes -- including secondary item/
recovery lookups -- is individually reservable.

Add the zabbix_metric_range and zabbix_problem_history observation.Executor
adapters, reading exact host/item-key or host/trigger-ID identifiers from
each plan's typed Parameters (never a model-invented metric key or fuzzy
cross-host fallback), with host falling back to the plan's own Scope
SubjectID when omitted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZWdCMiwFv57w2auUMWXUi
Signed-off-by: ernescz <ernescz@gmail.com>
Wires EvidencePreparer into Reconcile's control flow: a lifecycle-phase
prepare+reload runs first (guarded by a nonterminal Situation), source
lifecycle is reduced across every expected member Alert, and an
assessment-phase prepare+reload runs only while that reduction leaves
the Situation active. A nil preparer (every pre-Task-6 fixture) skips
both phases entirely, keeping local-only Reconcile behavior unchanged.

resolveLifecycle now branches on whether a preparation cycle exists for
this input: prepared evidence (source acquisition mode/interval, never
Delivery.StartedAtBasis alone) becomes authoritative, replacing the
local Delivery/Symptom inference only when it is actually present.

Store-side, LoadReconciliationInput now reloads the current preparation
cycle (Runs/Facts, frozen profile guidance, and source_lifecycle facts
decoded into SourceObservation) inside its own coherent transaction —
SnapshotInput.Prepared, never an in-memory EvidencePreparer receipt.

Also fixes a Task 3 naming bug: store_read's prior-situation fact was
mislabeled Kind "source_lifecycle" (migration 0022's reserved kind for
this task's own per-Alert evidence), colliding with genuine lifecycle
facts; it now uses the same generic "capability_result" kind
findingsFact already does.

Bumps fact schema to 2, material hash to 3, Assessment-basis hash to 4,
and validator to 2; replaces the blanket Plan 2 limitation catalog with
the closed, still-reserved Plan 5 policy set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZWdCMiwFv57w2auUMWXUi
Signed-off-by: ernescz <ernescz@gmail.com>
Adds migration 0023: the append-only delivery-to-signature mapping,
immutable Profile versions with a CAS-protected head, the deduplicated
inference-job/call/outcome ledger, and the head-change fan-out outbox
(spec.md "Source identity and advisory signatures" and "Durable
semantic-profile inference").

ApplySituationInput now attaches a delivery's deterministic advisory
signature (and, only for a genuinely new signature, enqueues its
pending inference job with a frozen semantic input) inside its own
existing transaction. BackfillActiveSemanticMappings recovers any
missed attachment for active/recovery_pending followers in bounded
batches, skipping terminal-only episodes.

CorrectSemanticProfile applies a CAS-protected operator override;
ClaimSemanticInferenceJob/ReserveSemanticInferenceCall/
CompleteSemanticInference give Task 8's worker its dispatch surface,
downgrading a late accepted inference to stale when a correction (or a
sibling job) already advanced the head first. RecoverSemanticInference
releases stranded leases, recovering a crash on the final attempt
directly as exhausted. DeliverSemanticProfileChanges fans a head
change out to every matching nonterminal Situation, paginated 100 per
transaction, merging the existing semantic_profile_changed due reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZWdCMiwFv57w2auUMWXUi
Signed-off-by: ernescz <ernescz@gmail.com>
Adds llm.InferenceLimiter: bounds concurrent Assessment (L2) and
semantic-profile (L0) provider calls to one shared capacity, capping
profile dispatch at one concurrently-held slot regardless of total
capacity and granting a queued Assessment first whenever both are
waiting. internal/situation.ControllerWorker now gates its own L2
dispatch through this SAME shared limiter when
SetInferenceLimiter wires one in (falling back to its private
per-worker semaphore otherwise, unchanged from before).

internal/llmhealth learns CapabilitySemanticProfile: a shared-primary
capability (a real success on it, or on triage_draft/assessment,
clears the others' dependency-class failures) that drives Degraded
rather than Unavailable when unhealthy — advisory guidance going dark
must not declare the core loop down. Migration 0024 widens the
capability CHECK constraint (SQLite cannot ALTER one in place).

internal/semanticprofile gains BuildInferencePrompt (renders the
frozen semantic input and the closed eight-field schema, no tools, no
model-authored queries) and Worker: claims one durable inference job
at a time, reserves exactly one dispatch before the request, heartbeats
the job's own lease while the call is in flight, and commits a typed
outcome (accepted/malformed/failed) through the shared limiter with
llmhealth observation — a store-side CAS loss downgrading a late
accepted result to stale is recorded as healthy transport, since the
model itself answered correctly. internal/store gains
ClaimSemanticInferenceJob/ReserveSemanticInferenceCall/
CompleteSemanticInference/ExtendSemanticInferenceJobLease, reusing
semanticprofile.ErrAttemptsExhausted directly so *Store structurally
satisfies Worker's own ProfileStore interface with no adapter shim.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZWdCMiwFv57w2auUMWXUi
Signed-off-by: ernescz <ernescz@gmail.com>
Wire the real EvidencePreparer adapter, semantic-profile inference
workers, and bounded sweeps into cmd/alertint (buildPreparationRuntime),
inject the preparer into every controller before workers start, and
recover/drain/stop it alongside the rest of the Situation runtime.

Add the three spec-named MCP tools: alertint_list_observation_runs,
alertint_get_semantic_profile, alertint_correct_semantic_profile (the
only new write path this plan adds). Add audit rows for cycle
begun/profile dispatch/outcome/head advance/change delivery/correction,
and OTel spans situation.preparation, situation.observation, and
semantic_profile.inference on their owning packages' existing tracer
scopes.

Fix a shared-preparation-wall gap found while wiring: Prepare() now
applies MaxWallSeconds as its own sub-deadline from the shared req.Now
anchor, so a phase that already spent part of the cycle's wall
correctly leaves less of it for the other.

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

reservedUnsupportedCapabilities is build-scoped, not input-derived, so an
old still-nonterminal Situation reconciled by a newer binary observes a
different limitation list at the SAME (situationID, inputVersion) an older
binary already committed a capability_limitation fact for.

Discovered live during Plan 4 lab acceptance: reconciling four pre-Plan-4
Situations against the upgraded candidate failed AppendSituationFacts' own
immutable-conflict check (same ID, different Value) on every cycle -- not a
crash-only edge case, ordinary post-upgrade operation.

Fix mirrors the existing factIdentityWithContent precedent
(incident_triage_state, acute_finding): fold the fact's own content digest
into its ID so a genuinely distinct observation gets its own row.
MaterialFactHash computes the reserved-capability contribution independently
from the package var, never from this fact's ID, so the fix has no effect
on material-hash stability or Assessment reuse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZWdCMiwFv57w2auUMWXUi
Signed-off-by: ernescz <ernescz@gmail.com>
The synchronous alertint_correct_semantic_profile write response echoed
CreatedAt as the zero value even though the row it just durably committed
carries the real correction instant -- an MCP caller reading the write's
own response (not a follow-up alertint_get_semantic_profile read) saw
"0001-01-01T00:00:00Z" for a correction that had genuinely just landed.

Discovered live during Plan 4 lab acceptance. The persisted row and every
subsequent read were always correct; only the write's own return value was
affected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZWdCMiwFv57w2auUMWXUi
Signed-off-by: ernescz <ernescz@gmail.com>
Real end-to-end proof that the production preparation/semantic-profile
wiring actually works together: real store, real ingress HTTP receiver,
real correlator/input workers, real ControllerWorker with the real
productionPreparer/observation.Runner/semanticprofile.Worker this package
builds for production, hitting a fake Prometheus HTTP server. Covers a
full pipeline from HTTP alert to committed run and profile head, a crash
that reuses the same frozen cycle, a stranded inference-job lease that
startup recovery resumes, and a connector outage that commits an honest
failed run without blocking the Situation's own lifecycle commit.

Deliberately a smaller, hand-written set of end-to-end tests rather than
a generic JSON-fixture-driven scenario corpus -- a scope reduction agreed
with the user to spend the freed budget on real lab SSH acceptance
instead (doctopus lab-acceptance.md; two real bugs found and fixed live
during that run, in separate commits on this branch).

Update public docs with the actual now-wired capabilities: bounded
evidence preparation, advisory semantic profiles, the three new MCP
tools, the shared L0+L2 limiter, retention, and source lifecycle
timestamp semantics.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZWdCMiwFv57w2auUMWXUi
Signed-off-by: ernescz <ernescz@gmail.com>
* fix(situation): apply assessment stability hotfix to Plan 4 lab

Port e41b63b onto the deployed Plan 4 baseline. Preserve observation hashing and advance material/basis schema versions to 4/5 because Plan 4 already uses 3/4.

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

* fix: prevent assessment evidence churn and bound shared LLM usage

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

* docs: record assessment reuse and LLM budget hotfix

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

---------

Signed-off-by: ernescz <ernescz@gmail.com>
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