Skip to content

Cortex — Signal Lifecycle

This spec adds lifecycle semantics and implementation guidance to the Signal object: transition rules, signal policy mechanics, accountability pack filtering, the signal→investigation triggering contract, and disposition semantics. The Signal schema (fields, enums, source types, severities) is authoritative in component.cortex.decision-evidence#signal.

What a Signal Is

A Signal IS a trigger that may warrant human attention; the entry point to the OODA loop (Monitor → Observe → Orient → Decide → Act); filterable by accountability pack (policies + severity); linkable to one or more investigations; and disposable (resolved or dismissed with audit trail).

A Signal IS NOT evidence (Blocks are evidence), a task (Tasks are deliberation work items), a decision (Editions are decisions), or immutable (Signal status changes, unlike append-only Events).

Cortex stores additional fields under metadata (see component.cortex.decision-evidence#signal.fields): created_by, status_history[], resolved_by_edition, resolved_by_insight, and ad-hoc geolocation.

Persistence — Ledger as Source of Truth

A Signal is an append-only fact on the InsightEvent ledger, not a mutable ES row. The Signal schema stays authoritative in component.cortex.decision-evidence#signal; this section governs how a signal is written and read.

One write path. Every producer — REST POST /signal, the MCP signal_create tool, in-process detectors — funnels through cortex.signals.ingest.emit_signal. The funnel is the single point that:

  • validates severity (#signal.severity), source.type (#signal.sources), and the presence of a subject (the subject-required invariant), plus payload.assessment shape when present — rejecting before any append;
  • stamps signal_id (sig_+12hex), schema_version, detected_at, status = new;
  • dedups replays within 24h of (idempotency_key, source.system_id) (replays return identifiers only, never the cached document — cross-caller ABAC isolation);
  • appends a signal_created event (signal-scoped: no insight_id; payload.signal holds the full document);
  • projects the document into the ES signal alias (the derived read model).

The index is a projection. signals/projection.py::rebuild_from_ledger replays signal_created then folds signal_status_changed (oldest-first) to reconstruct the signal index from the ledger alone. Dropping and rebuilding the index is supported and lossless. A divergence between ledger and index is resolved by replay; the index is never the authority.

Live tail (SSE). Beacon subscribes to a read-only SSE tail of the ledger (GET /events/signals/stream) for signal_created, signal_status_changed, signal_linked. Resumable via Last-Event-ID/after, bounded connection age; reconnect-replay is lossless because the ledger is the source of truth.

Webhook ingress (deferred). External systems as peer detectors (POST /webhooks/signals/{adapter}, mounted outside OAuth, per-adapter HMAC + statically-registered visibility marking) are deferred to their own MR under SPEC-PLATFORM-17 (Workload Identity & Marking Grants), which subsumes the static-marking approach. When it lands it funnels through the same emit_signal.

Signal Lifecycle States

NEW ──ack──> ACKNOWLEDGED ──investigate──> INVESTIGATING ──resolve──> RESOLVED
  │              │                              │
  │              │                              └──dismiss──> DISMISSED
  │              └──dismiss──> DISMISSED
  └──dismiss──> DISMISSED
  └──expire──> (auto-dismissed, see §expiry)
Status Meaning Who Transitions
new Signal detected, no human has reviewed it System (on creation)
acknowledged A human has seen this signal User (signal_acknowledge())
investigating An investigation is actively examining this signal User/System (when insight linked)
resolved Signal addressed — underlying condition acted upon User (signal_set_disposition(), typically linked to edition)
dismissed Signal reviewed, determined not to require action User (signal_set_disposition() / signal_archive(), requires rationale)

Transition Rules

Intended progression (forward-only):

From Valid Targets Gate
new acknowledged, investigating, dismissed Any authorized user
acknowledged investigating, resolved, dismissed Any authorized user
investigating resolved, dismissed User with linked insight
resolved — (terminal)
dismissed — (terminal)

Shortcut transitions allowed: new → investigating (creating an investigation directly from a signal, via create_insight_from_signal()) and new → dismissed (triaging a signal as not relevant without investigating).

Transition validation (#REQ.transition-validation) — implemented. The lifecycle commands in cortex/signal.py validate every transition against SIGNAL_TRANSITIONS and reject invalid edges; creation-time validation lives in the emission funnel. The enforced map (signal.py:62):

SIGNAL_TRANSITIONS = {
    "new": {"acknowledged", "investigating", "dismissed"},
    "acknowledged": {"investigating", "dismissed"},
    "investigating": {"resolved", "dismissed"},
    "resolved": set(),     # terminal
    "dismissed": set(),    # terminal
}

Note: the enforced map does not include a direct acknowledged → resolved edge — an acknowledged signal must pass through investigating before it can be resolved. (The §states transition table above lists acknowledged → resolved, which diverges from the enforced map; the code is authoritative.)

Terminal States

resolved and dismissed are terminal. When a signal is dismissed, the dismissal should eventually be backed by an attested edition (a no_action decision per component.cortex.decision-evidence#no-action). Low-severity signals may be dismissed without a full edition, but high-severity dismissals should produce an evidentiary record.

Event Ledger Integration

Signal lifecycle is recorded as append-only events (see component.cortex.decision-evidence#event):

Signal Action Event Type Payload
Signal created (any producer) signal_created {signal_id, content_hash, signal: <full doc>} (signal-scoped — no insight_id)
Signal status changed (any transition) signal_status_changed {signal_id, from, to, rationale?, ...} (signal-scoped — no insight_id)
Signal linked to insight signal_linked {signal_id, rationale?, auto_linked?} (on the linked investigation's ledger)
Signal resolved/dismissed/investigating, when linked signal_disposition_set {signal_id, disposition, rationale?, edition_id?} (on the linked investigation's ledger)

signal_created and signal_status_changed are signal-scoped events — anchored by payload.signal_id, carrying no insight_id. A status change appends signal_status_changed regardless of whether the signal is linked to an investigation (it is the durable record of the transition; emit_status_change covers acknowledge, disposition, and archive). signal_linked / signal_disposition_set are written onto a linked investigation's ledger as before. There is no separate signal_acknowledged event — acknowledgement is a signal_status_changed with to = acknowledged.

Signal Sources

Source types are defined in component.cortex.decision-evidence#signal.sources. Two production-relevant categories:

Computed Signals (Signal Policy Pipeline)

Computed signals are the primary production mechanism — generated by evaluating signal policies against data models:

Data Model (core_loans, risk_profile, ...)
    ├── Signal Policy evaluates field against thresholds
    │   policy_id: dscr_breach; source_model: core_loans; field: dscr_last_qtr_#; threshold: < 1.20 → high
    └── Signal created with: source.type=computed, signal_type=dscr_breach, severity=high, subject=customer from row

System-Generated Signals

The system generates signals for governance events using source.type: internal:

Signal Type When Generated Severity
task_sla_breach Task exceeds SLA deadline without completion high
effect_timeout Decision effect deadline elapsed high
signal_expiry_warning Signal approaching expires_at without disposition medium

System signals have source.system_id: cortex-sla-engine (or equivalent) and have no source_model. Signal policy packs document that system signals bypass source_model matching.

Signal Policy System

Signal policies are pack-level configuration declaring how signals are computed from data models. They live in signal_policies.yaml per domain bundle (schema in component.cortex.pack-reference#signal-policy).

signal_policies_id: thebank_signal_policies_v1
policies:
  - policy_id: dscr_breach
    name: DSCR Covenant Breach Risk
    severity_default: high
    source_model: core_loans
    computation:
      type: threshold        # threshold | boolean
      field: "dscr_last_qtr_#"
      thresholds:
        - condition: "< 1.00"
          severity: critical
        - condition: "< 1.20"
          severity: high
        - condition: "< 1.35"
          severity: medium

Policy Fields

Field Purpose
policy_id Unique id, referenced by accountability pack signals.policies[]
name / description Human-readable name / what it detects
severity_default Default severity when no threshold matches
source_model Data model that triggers evaluation (must exist in domain.yaml)
computation.type threshold or boolean
computation.field Field in the data model to evaluate
computation.thresholds[] Ordered conditions with severity + reason
computation.score_weight Weight in composite score calculation

Composite Scoring

composite_score:
  name: risk_signal_score
  weights:
    watchlist_flag: 50
    dscr_breach: { critical: 40, high: 25, medium: 10 }
  min_score_threshold: 30

When the composite score exceeds min_score_threshold, a summary signal is generated.

Cross-Reference Validation

Bundle validators must check: every source_model in signal_policies.yaml exists in domain.yaml models; every policy_id referenced by accountability.yaml → signals.policies[] exists in signal_policies.yaml; threshold conditions reference valid fields in the source model.

Accountability Pack Filtering

The accountability pack controls which signals a profile can see via two filters:

thebank_rm_v1:
  signals:
    policies: [utilization_spike, high_ltv, high_exposure]   # which signal types
    severity_filter: [critical, high]                        # which severities

Filter logic (list_signals()): a signal is skipped if allowed_policies is set and signal.signal_type not in it, or if allowed_severities is set and signal.severity not in it. Wildcard "*" in either list means no restriction.

TheBank Filter Matrix

Profile Policies Seen Severities Seen
RM (Jane Chen) utilization_spike, high_ltv, high_exposure critical, high
Risk (Marcus Rivera) ALL 6 policies critical, high, medium
Treasury (Sara Kim) high_exposure, concentration_flag critical, high
Auditor none (read-only, no signal queue) none

ABAC Layering

Accountability pack filtering runs on top of UDS ABAC. Flow: UDS/REST returns all signals the user's token can read (visibility_keys match) → Cortex loads the accountability pack → Cortex filters by policies[] and severity_filter[] → user sees the intersection. UDS is the sole ABAC authority; the accountability pack is an additional business-logic filter, not a security boundary.

Signal → Investigation Triggering

Two Paths

Path Mechanism Tool
Explicit User selects signal → "Investigate" create_insight_from_signal(signal_id)
Session User starts investigation with signal in entry_context start_investigation(entry_context={trigger:{type:signal, id:sig_xxx}})

Both paths: create an InsightRoot with entry_context.mode: signal_driven; set entry_context.trigger: {type: signal, id: <signal_id>}; copy the signal's subject to entry_context.subject_ref; auto-link signal to insight (bidirectional); append signal_linked event; optionally set signal status to investigating.

Accountability Gate

Before creating a signal-driven investigation, Cortex checks the accountability pack via check_entry_mode(pack, "signal_driven") → 403 ACCOUNTABILITY_ENTRY_MODE_DENIED if the profile's pack lacks signal_driven in insights.entry_modes.

Deduplication

start_investigation() includes ES-based deduplication: if a non-archived investigation already exists for the same trigger signal, it returns the existing investigation. Bypassed with force_new=True.

Signal Disposition

"Disposition" is the final determination: was the signal addressed (resolved) or determined to not require action (dismissed)?

Disposition Meaning Typical Driver
resolved Underlying condition investigated and a decision made Attested edition with decision_type: action
dismissed Reviewed and determined not to warrant action Attested edition with decision_type: no_action, or direct triage

signal_set_disposition() sets status, records the change in metadata.status_history[], stores metadata.resolved_by_edition / resolved_by_insight, appends a signal_status_changed event (the signal-scoped durable record), and — when the signal is linked to an insight — also writes a signal_disposition_set event onto that investigation's ledger.

No-Action Requirement

Per component.cortex.decision-evidence#no-action, "no_action" is an explicit, attested decision. When dismissing a signal: high/critical should produce an edition with decision_type: no_action, attestation, and rationale (dismissal backed by frozen evidence); medium/low/info may be dismissed with rationale alone — the signal_disposition_set event provides the audit trail.

The Complete Audit Chain

Signal detected → acknowledged → Investigation created (entry_intent_set)
  → Signal linked (signal_linked) → Evidence gathered (block_created, block_pinned)
  → Decision made (edition_created, revision_committed) → Attested (attested)
  → Signal resolved (signal_disposition_set)

Every step is recorded and traceable; an auditor can reconstruct the full chain from signal to decision.

Signal Expiry

Signals may carry an optional expires_at. After this time the signal is stale.

Expiry behavior (design — not implemented): when a signal expires without disposition, the system sets status to dismissed ("Signal expired without disposition") and optionally generates a signal_expiry_warning signal for governance. Currently expires_at is stored but not enforced. Open governance decision: auto-dismiss vs. escalation signal (both — auto-dismiss plus escalation for high-severity).

Signals can reference others via related_signals: [sig_xxx, sig_yyy], capturing signals from the same subject, of the same type within a time window (cluster detection), or that together form a pattern.

Pattern Example
Cluster 3 DSCR breach signals for the same customer within 30 days
Compound DSCR breach + watchlist flag + high exposure = compound risk
Escalation task_sla_breach references the signal that created the original investigation
Temporal Disease cluster signal references the initial pathogen detection

Related signals are an informative link. They do NOT create automatic investigations, change severity, or trigger automatic escalation — those require human or policy-driven decisions.

Implementation Status

What exists today: Signal creation funnels through cortex.signals.ingest.emit_signal — the single validating write path (severity / source.type / subject presence + payload.assessment shape), appending signal_created to the ledger. The ES signal index is a derived projection rebuildable from the ledger (cortex/signals/projection.py::rebuild_from_ledger). Lifecycle commands live in cortex/signal.py: signal_acknowledge, signal_set_disposition, signal_archive, signal_link_insight — each validates the transition against SIGNAL_TRANSITIONS, updates the projection, and appends signal_status_changed (the durable transition record). Read tools (list_signals with accountability filtering, count_signals, get_signal, trace_entity_relationships) remain in cortex/tools/signal.py. Signal policies YAML authored; accountability signal filtering (policies + severity_filter); signal-driven investigation creation; auto-link on start_investigation; ES-based deduplication.

The funnel-bypassing direct-write tools (create_signal, acknowledge_signal, archive_signal, set_signal_status in tools/signal.py) that wrote the index without a ledger event have been removed; the live surface routes through crud / cortex.signal only.

What's missing (priority order):

ID Gap Priority
S1 ~~Status transition validation~~ — closed: enforced in cortex/signal.py against SIGNAL_TRANSITIONS (and at creation in the emission funnel) done
S2 Signal expiry enforcement (expires_at → auto-dismiss or escalation) P2 — production
S3 Signal policy evaluation engine (compute signals from data models) P2 — production
S4 Composite score computation P3 — production
S5 signal_expiry_warning system signal generation P3 — governance
S6 Related signals auto-detection (same subject/type/time window) P4
S7 Signal confidence recalculation on new evidence P4

The signal policy evaluation engine (S3) would subscribe to data model changes (or run on schedule), query each policy's source_model, evaluate the computation against each row, create signals for rows that breach thresholds, and avoid duplicates (same subject + policy_id + time window). Architecture: an external service (signal engine), since Cortex is an MCP server, not a scheduler.


Depends on: component.cortex.decision-evidence, component.cortex.pack-reference

Realizes: product.signal