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 asubject(the subject-required invariant), pluspayload.assessmentshape 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_createdevent (signal-scoped: noinsight_id;payload.signalholds the full document); - projects the document into the ES
signalalias (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).
Related Signals
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