Finish KG-WP-0003: stream completeness and live qonto observation

Classify evidence as load-bearing or attributive, draft the emission-cadence
declaration for Taxonomy, treat silence as a stream finding, keep completeness
separate from record richness, forbid immune memory as a state plane, and make
containment proposals reconstructable to their origin. Observe real
qonto-assistant audit events; deny-class completeness stays unknown until the
source publishes a heartbeat.

Assistant: grok
Assistant-Session: 01a05ef1-9e5a-70f2-b0ff-0b05d6b38ae9
This commit is contained in:
tegwick 2026-09-02 00:11:57 +02:00
parent c85646dc3c
commit 9daea96c43
35 changed files with 2023 additions and 138 deletions

View file

@ -3,7 +3,12 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from kings_guard.contracts import ImmuneObservation, ObservationDecision
from kings_guard.contracts import (
EvidenceClass,
ImmuneObservation,
ObservationDecision,
SecurityGenome,
)
def observation_from_audit_event(
@ -13,8 +18,24 @@ def observation_from_audit_event(
capability_scope: str,
identity_binding: str,
egress_destination: str | None,
genome: SecurityGenome | None = None,
evidence_class: EvidenceClass | str | None = None,
event_class: str | None = None,
) -> ImmuneObservation:
"""Normalize qonto-assistant's audit stream into Kings Guard's observation contract."""
"""Normalize qonto-assistant's audit stream into Kings Guard's observation contract.
Evidence class is copied from the source's declaration (the genome) or an
explicit caller-supplied declaration. It is never inferred from the event
body a deny is load-bearing because the source said so, not because
kings-guard recognized the string "deny".
"""
decision = ObservationDecision(str(event["decision"]))
resolved_event_class = event_class or f"audit.{decision.value}"
resolved_class = _declared_evidence_class(
genome=genome,
event_class=resolved_event_class,
evidence_class=evidence_class,
)
return ImmuneObservation(
observation_id=str(event["request_id"]),
source_system="qonto-assistant",
@ -25,7 +46,9 @@ def observation_from_audit_event(
capability=capability_scope,
resource_scope=_optional_str(event.get("capability")),
protocol=str(event["protocol"]),
decision=ObservationDecision(str(event["decision"])),
decision=decision,
evidence_class=resolved_class,
event_class=resolved_event_class,
deny_reason=_optional_str(event.get("deny_reason")),
identity_binding=identity_binding,
egress_destination=egress_destination,
@ -36,6 +59,36 @@ def observation_from_audit_event(
)
def _declared_evidence_class(
*,
genome: SecurityGenome | None,
event_class: str,
evidence_class: EvidenceClass | str | None,
) -> EvidenceClass:
if evidence_class is not None:
declared = (
evidence_class
if isinstance(evidence_class, EvidenceClass)
else EvidenceClass(str(evidence_class))
)
if genome is not None:
source = genome.source_for(event_class)
if source is not None and source.evidence_class != declared:
raise ValueError(
f"supplied evidence class {declared.value!r} does not match "
f"source declaration {source.evidence_class.value!r} for {event_class}"
)
return declared
if genome is not None:
source = genome.source_for(event_class)
if source is not None:
return source.evidence_class
raise ValueError(
"evidence class must be declared by the source (genome) or supplied "
"explicitly; kings-guard does not infer it from event contents"
)
def _optional_int(value: Any) -> int | None:
if value is None:
return None

168
src/kings_guard/cadence.py Normal file
View file

@ -0,0 +1,168 @@
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from importlib.resources import files
from typing import Any
from kings_guard.contracts import CadenceForm, EvidenceClass
def parse_timestamp(value: str) -> datetime:
text = value.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
parsed = datetime.fromisoformat(text)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
def parse_interval(value: str | int) -> timedelta:
if isinstance(value, int):
return timedelta(seconds=value)
text = str(value).strip().lower()
if text.endswith("s") and text[:-1].isdigit():
return timedelta(seconds=int(text[:-1]))
if text.endswith("m") and text[:-1].isdigit():
return timedelta(minutes=int(text[:-1]))
if text.endswith("h") and text[:-1].isdigit():
return timedelta(hours=int(text[:-1]))
if text.endswith("d") and text[:-1].isdigit():
return timedelta(days=int(text[:-1]))
if text.startswith("pt"):
# Minimal ISO-8601 duration: PT24H, PT1H, PT30M.
amount = text[2:]
if amount.endswith("h") and amount[:-1].isdigit():
return timedelta(hours=int(amount[:-1]))
if amount.endswith("m") and amount[:-1].isdigit():
return timedelta(minutes=int(amount[:-1]))
if amount.endswith("s") and amount[:-1].isdigit():
return timedelta(seconds=int(amount[:-1]))
raise ValueError(f"unsupported interval: {value!r}")
@dataclass(frozen=True, slots=True)
class RateCadence:
event_class: str
evidence_class: EvidenceClass
window: timedelta
expected_min: int
@dataclass(frozen=True, slots=True)
class HeartbeatCadence:
event_class: str
covered_event_class: str
evidence_class: EvidenceClass
interval: timedelta
assertion: str
@dataclass(frozen=True, slots=True)
class ReconciliationCadence:
covered_event_class: str
evidence_class: EvidenceClass
local_field: str
observed_field: str
@dataclass(frozen=True, slots=True)
class EmissionCadence:
"""Runtime view of the Taxonomy draft, loaded from the local worked example.
This is a consumer of the draft in `specs/EmissionCadenceDeclaration.md`,
not a competing schema. Ownership stays with Taxonomy.
"""
schema_version: str
status: str
drafter: str
owner: str
source_system: str
reference_instance: str
rates: tuple[RateCadence, ...]
heartbeats: tuple[HeartbeatCadence, ...]
reconciliations: tuple[ReconciliationCadence, ...]
def forms(self) -> frozenset[CadenceForm]:
forms: set[CadenceForm] = set()
if self.rates:
forms.add("expected-rate")
if self.heartbeats or self.reconciliations:
forms.add("heartbeat-or-reconciliation")
return frozenset(forms)
def load_qonto_assistant_cadence() -> EmissionCadence:
payload = json.loads(
files("kings_guard")
.joinpath("fixtures")
.joinpath("qonto_assistant_cadence.json")
.read_text(encoding="utf-8")
)
return emission_cadence_from_dict(payload)
def emission_cadence_from_dict(data: Mapping[str, Any]) -> EmissionCadence:
rates: list[RateCadence] = []
heartbeats: list[HeartbeatCadence] = []
reconciliations: list[ReconciliationCadence] = []
for item in data.get("sources", ()):
evidence_class = EvidenceClass(str(item["evidence_class"]))
form = str(item["form"])
if form == "expected-rate":
rates.append(
RateCadence(
event_class=str(item["event_class"]),
evidence_class=evidence_class,
window=parse_interval(item.get("window_seconds", item.get("window"))),
expected_min=int(item["expected_min"]),
)
)
continue
if form != "heartbeat-or-reconciliation":
raise ValueError(f"unknown cadence form: {form}")
heartbeat = item.get("heartbeat") or {}
if heartbeat:
heartbeats.append(
HeartbeatCadence(
event_class=str(heartbeat["event_class"]),
covered_event_class=str(item["event_class"]),
evidence_class=evidence_class,
interval=parse_interval(
heartbeat.get("interval_seconds", heartbeat.get("interval"))
),
assertion=str(heartbeat.get("assertion", "nothing-to-report")),
)
)
reconciliation = item.get("reconciliation") or {}
if reconciliation:
reconciliations.append(
ReconciliationCadence(
covered_event_class=str(item["event_class"]),
evidence_class=evidence_class,
local_field=str(reconciliation.get("compare_local", "source_counts")),
observed_field=str(reconciliation.get("compare_observed", "evidence_counts")),
)
)
return EmissionCadence(
schema_version=str(data.get("schema_version", "0.1")),
status=str(data.get("status", "taxonomy-draft")),
drafter=str(data.get("drafter", "kings-guard")),
owner=str(data.get("owner", "Taxonomy")),
source_system=str(data.get("source", data.get("source_system", "unknown"))),
reference_instance=str(data.get("reference_instance", "GH-WP-0002-T04")),
rates=tuple(rates),
heartbeats=tuple(heartbeats),
reconciliations=tuple(reconciliations),
)
def count_event_classes(event_classes: Sequence[str]) -> dict[str, int]:
counts: dict[str, int] = {}
for event_class in event_classes:
counts[event_class] = counts.get(event_class, 0) + 1
return counts

View file

@ -12,12 +12,36 @@ AuthorityBoundary = Literal[
"local_service_owned",
"requires_human_approval",
]
RestrictiveDirection = Literal[
"reduce_authority",
"require_step_up",
"request_containment",
"none",
]
CadenceForm = Literal["expected-rate", "heartbeat-or-reconciliation"]
MemoryRuntimeDependency = Literal["forbidden"]
class EvidenceClass(str, Enum):
"""Source-declared evidence class (§9.6). Not inferred by kings-guard."""
LOAD_BEARING = "load-bearing"
ATTRIBUTIVE = "attributive"
class StreamCompleteness(str, Enum):
"""Completeness of the *stream*, never richness of the record in hand."""
COMPLETE = "complete"
DEGRADED = "degraded"
UNKNOWN = "unknown"
class ObservationDecision(str, Enum):
ALLOW = "allow"
DENY = "deny"
ERROR = "error"
HEARTBEAT = "heartbeat"
class PostureLevel(str, Enum):
@ -31,6 +55,16 @@ class SignalKind(str, Enum):
POSTURE_HINT = "posture_hint"
OBSERVATION_ALERT = "observation_alert"
RECOVERY_REQUEST = "recovery_request"
STREAM_COMPLETENESS = "stream_completeness"
STREAM_FINDING_PREFIX = "stream:"
COMPLETENESS_RANK = {
StreamCompleteness.UNKNOWN: 0,
StreamCompleteness.DEGRADED: 1,
StreamCompleteness.COMPLETE: 2,
}
@dataclass(frozen=True, slots=True)
@ -52,6 +86,34 @@ class ToleranceRule:
)
@dataclass(frozen=True, slots=True)
class DeclaredEvidenceSource:
"""A source the subject declares, including its evidence class.
The class is the source's declaration. kings-guard copies it onto
observations; it does not infer it from event contents.
"""
source_id: str
source_system: str
event_class: str
evidence_class: EvidenceClass
reasoning: str
cadence_form: CadenceForm | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "DeclaredEvidenceSource":
cadence_form = data.get("cadence_form")
return cls(
source_id=str(data["source_id"]),
source_system=str(data["source_system"]),
event_class=str(data["event_class"]),
evidence_class=EvidenceClass(str(data["evidence_class"])),
reasoning=str(data["reasoning"]),
cadence_form=str(cadence_form) if cadence_form else None,
)
@dataclass(frozen=True, slots=True)
class SecurityGenome:
genome_id: str
@ -64,6 +126,7 @@ class SecurityGenome:
permitted_egress: frozenset[str]
data_classifications: tuple[str, ...] = ()
tolerances: tuple[ToleranceRule, ...] = ()
evidence_sources: tuple[DeclaredEvidenceSource, ...] = ()
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "SecurityGenome":
@ -80,8 +143,18 @@ class SecurityGenome:
tolerances=tuple(
ToleranceRule.from_dict(item) for item in data.get("tolerances", ())
),
evidence_sources=tuple(
DeclaredEvidenceSource.from_dict(item)
for item in data.get("evidence_sources", ())
),
)
def source_for(self, event_class: str) -> DeclaredEvidenceSource | None:
for source in self.evidence_sources:
if source.event_class == event_class:
return source
return None
@dataclass(frozen=True, slots=True)
class ImmuneObservation:
@ -95,6 +168,8 @@ class ImmuneObservation:
resource_scope: str | None
protocol: str
decision: ObservationDecision
evidence_class: EvidenceClass
event_class: str
deny_reason: str | None = None
identity_binding: str | None = None
egress_destination: str | None = None
@ -116,6 +191,8 @@ class ImmuneObservation:
resource_scope=_optional_str(data.get("resource_scope")),
protocol=str(data["protocol"]),
decision=ObservationDecision(str(data["decision"])),
evidence_class=EvidenceClass(str(data["evidence_class"])),
event_class=str(data["event_class"]),
deny_reason=_optional_str(data.get("deny_reason")),
identity_binding=_optional_str(data.get("identity_binding")),
egress_destination=_optional_str(data.get("egress_destination")),
@ -126,6 +203,25 @@ class ImmuneObservation:
)
@dataclass(frozen=True, slots=True)
class StreamHeartbeat:
"""A signed positive claim that can itself go missing (§9.6)."""
source_system: str
timestamp: str
event_class: str
assertion: str
counts: Mapping[str, int] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ReconciliationView:
"""Source transition counts versus observed evidence counts per event class."""
source_counts: Mapping[str, int]
evidence_counts: Mapping[str, int]
@dataclass(frozen=True, slots=True)
class SecurityPhenotype:
subject_id: str
@ -142,11 +238,22 @@ class PostureAssessment:
posture: PostureLevel
risk_score: int
confidence_score: int
stream_completeness: StreamCompleteness
completeness_reason: str
findings: tuple[str, ...]
tolerated_findings: tuple[str, ...]
rationale: str
@dataclass(frozen=True, slots=True)
class StreamAssessment:
completeness: StreamCompleteness
reason: str
findings: tuple[str, ...]
observed_counts: Mapping[str, int]
window_end: str
@dataclass(frozen=True, slots=True)
class EffectorRequest:
target_system: str
@ -154,6 +261,10 @@ class EffectorRequest:
authority_boundary: AuthorityBoundary
reason: str
requires_human_approval: bool
originating_observation_id: str
originating_signal_id: str
stream_completeness: StreamCompleteness
restrictive_direction: RestrictiveDirection = "none"
@dataclass(frozen=True, slots=True)
@ -170,12 +281,15 @@ class ImmuneSignal:
@dataclass(frozen=True, slots=True)
class ImmuneMemoryEntry:
"""Governed defensive learning. Not a runtime input for any other layer."""
memory_id: str
subject_scope: str
summary: str
derived_from: tuple[str, ...]
recommended_countermeasures: tuple[str, ...]
confidentiality: str = "non-secret"
runtime_input_for_other_layers: MemoryRuntimeDependency = "forbidden"
@dataclass(frozen=True, slots=True)
@ -183,13 +297,23 @@ class PostureEvaluation:
phenotype: SecurityPhenotype
assessment: PostureAssessment
signals: tuple[ImmuneSignal, ...]
stream: StreamAssessment | None = None
def assessment_trust_key(assessment: PostureAssessment) -> tuple[int, int]:
"""Order judgments so completeness outranks record richness.
An incomplete stream can never read as more trustworthy than a complete
one, regardless of how well-formed the record in hand is.
"""
return (COMPLETENESS_RANK[assessment.stream_completeness], assessment.confidence_score)
def as_jsonable(value: Any) -> Any:
"""Convert contract objects into JSON-safe primitives."""
if isinstance(value, Enum):
return value.value
if is_dataclass(value):
if is_dataclass(value) and not isinstance(value, type):
return {key: as_jsonable(item) for key, item in asdict(value).items()}
if isinstance(value, Mapping):
return {str(key): as_jsonable(item) for key, item in value.items()}

View file

@ -5,6 +5,7 @@ from dataclasses import dataclass
from importlib.resources import files
from typing import Any
from kings_guard.cadence import EmissionCadence, load_qonto_assistant_cadence
from kings_guard.contracts import SecurityGenome
@ -14,6 +15,7 @@ class QontoAssistantPilotFixture:
audit_event: dict[str, Any]
normalization_hints: dict[str, str]
source_notes: tuple[str, ...]
evidence_class_reasoning: tuple[str, ...]
def load_qonto_assistant_pilot() -> QontoAssistantPilotFixture:
@ -21,11 +23,20 @@ def load_qonto_assistant_pilot() -> QontoAssistantPilotFixture:
return QontoAssistantPilotFixture(
genome=SecurityGenome.from_dict(payload["normalized_genome"]),
audit_event=dict(payload["qonto_audit_event"]),
normalization_hints={str(key): str(value) for key, value in payload["normalization_hints"].items()},
normalization_hints={
str(key): str(value) for key, value in payload["normalization_hints"].items()
},
source_notes=tuple(str(item) for item in payload.get("source_notes", ())),
evidence_class_reasoning=tuple(
str(item) for item in payload.get("evidence_class_reasoning", ())
),
)
def load_pilot_cadence() -> EmissionCadence:
return load_qonto_assistant_cadence()
def _load_json_fixture(name: str) -> dict[str, Any]:
fixture_path = files("kings_guard").joinpath("fixtures").joinpath(name)
return json.loads(fixture_path.read_text(encoding="utf-8"))

View file

@ -0,0 +1,44 @@
{
"schema_version": "0.1",
"status": "taxonomy-draft",
"drafter": "kings-guard",
"owner": "Taxonomy",
"source": "qonto-assistant",
"belongs_alongside": "security_genome",
"reference_instance": "GH-WP-0002-T04",
"reference_source_declaration": "approval-engine/cadence.yaml",
"sources": [
{
"source_id": "qonto-assistant.audit.allow",
"source_system": "qonto-assistant",
"event_class": "audit.allow",
"evidence_class": "attributive",
"form": "expected-rate",
"window": "24h",
"window_seconds": 86400,
"expected_min": 1,
"drop_below": "finding",
"note": "Worked example of the volume form. qonto-assistant is called sporadically, so this rate is a SHOULD illustration, not a claim that completeness of allows is currently meaningful."
},
{
"source_id": "qonto-assistant.audit.deny",
"source_system": "qonto-assistant",
"event_class": "audit.deny",
"evidence_class": "load-bearing",
"form": "heartbeat-or-reconciliation",
"rate_monitoring": "forbidden",
"heartbeat": {
"event_class": "audit.heartbeat",
"interval": "24h",
"interval_seconds": 86400,
"assertion": "nothing-to-report",
"missing": "finding"
},
"reconciliation": {
"compare_local": "source_transition_counts.audit.deny",
"compare_observed": "evidence_counts.audit.deny",
"divergence": "finding"
}
}
]
}

View file

@ -28,6 +28,32 @@
"description": "Actor identity is still self-asserted until key-cape integration lands.",
"effect": "monitor"
}
],
"evidence_sources": [
{
"source_id": "qonto-assistant.audit.allow",
"source_system": "qonto-assistant",
"event_class": "audit.allow",
"evidence_class": "attributive",
"cadence_form": "expected-rate",
"reasoning": "Allow records support forensic reconstruction. No control currently branches on an allow being present or absent, so the class is attributive and completeness is not claimed."
},
{
"source_id": "qonto-assistant.audit.deny",
"source_system": "qonto-assistant",
"event_class": "audit.deny",
"evidence_class": "load-bearing",
"cadence_form": "heartbeat-or-reconciliation",
"reasoning": "qonto-assistant's deny-escalation loop and kings-guard posture both branch on deny presence or absence. Statute §9.6 names denials as load-bearing. The class is this source's declaration, not an inference from the string 'deny'."
},
{
"source_id": "qonto-assistant.audit.heartbeat",
"source_system": "qonto-assistant",
"event_class": "audit.heartbeat",
"evidence_class": "load-bearing",
"cadence_form": "heartbeat-or-reconciliation",
"reasoning": "The positive nothing-to-report claim for the deny class. Rate monitoring cannot work on infrequent denials; the heartbeat is the claim that can itself go missing."
}
]
},
"qonto_audit_event": {
@ -50,9 +76,15 @@
"identity_binding": "self_asserted",
"egress_destination": "qonto-thirdparty-api"
},
"evidence_class_reasoning": [
"audit.deny is load-bearing because deny-escalation and posture branch on it; the source declares that class.",
"audit.allow is attributive: forensic reconstruction only; completeness is not claimed.",
"kings-guard copies the declared class onto the observation and does not infer it from decision=deny."
],
"source_notes": [
"Derived from qonto-assistant/specs/security-genome.yaml",
"Derived from qonto-assistant/src/qonto_assistant/contracts.py#AuditEvent",
"Pilot chooses qonto-assistant because it already ships an audit stream, a genome record, and a fast local loop."
"Pilot chooses qonto-assistant because it already ships an audit stream, a genome record, and a fast local loop.",
"This JSON remains the regression fixture. Live emitted events are captured separately by kings_guard.live."
]
}

198
src/kings_guard/live.py Normal file
View file

@ -0,0 +1,198 @@
from __future__ import annotations
import json
import sys
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from kings_guard.adapters import observation_from_audit_event
from kings_guard.contracts import ImmuneObservation, SecurityGenome
QONTO_ASSISTANT_SRC = Path("/home/worsch/qonto-assistant/src")
QONTO_POLICY = (
Path("/home/worsch/qonto-assistant/src/qonto_assistant/policy/qonto-v1.yaml")
)
QONTO_FIXTURES = Path("/home/worsch/qonto-assistant/tests/fixtures/qonto")
@dataclass(frozen=True, slots=True)
class LiveQontoCapture:
events: tuple[dict[str, Any], ...]
observations: tuple[ImmuneObservation, ...]
mapping_notes: tuple[str, ...]
corrections_for_source: tuple[str, ...]
def qonto_assistant_available() -> bool:
return (QONTO_ASSISTANT_SRC / "qonto_assistant" / "service.py").is_file()
def capture_qonto_assistant_events(genome: SecurityGenome) -> LiveQontoCapture:
"""Drive qonto-assistant's real emit path and capture what it publishes.
Uses the adjacent checkout and its fixture-backed client. No Tooling
client is opened; the source publishes its own stream.
"""
if not qonto_assistant_available():
raise FileNotFoundError(
f"qonto-assistant checkout not found at {QONTO_ASSISTANT_SRC}"
)
src = str(QONTO_ASSISTANT_SRC)
if src not in sys.path:
sys.path.insert(0, src)
from qonto_assistant.audit import AuditLogger
from qonto_assistant.contracts import ActorClaims
from qonto_assistant.errors import PolicyDeniedError
from qonto_assistant.policy import PolicyEngine
from qonto_assistant.qonto_client import FixtureQontoClient
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
from qonto_assistant.service import CapabilityService
events: list[dict[str, Any]] = []
service = CapabilityService(
client=FixtureQontoClient(fixture_dir=QONTO_FIXTURES),
policy=PolicyEngine.from_file(
QONTO_POLICY,
required_scope="finance.qonto.read",
enforce_scope=False,
),
audit_logger=AuditLogger(sink=events.append),
rate_limiter=RateLimiter(limit=100, window_seconds=60),
concurrency_limiter=ConcurrencyLimiter(limit=4),
)
claims = ActorClaims(actor_id="kg-live-observer", tenant_id="binky", lane="green")
allow_payload = service.get_accounts(
claims=claims, request_id="req-kg-live-allow", protocol="rest"
)
if not isinstance(allow_payload, Mapping):
raise RuntimeError("qonto-assistant allow path did not return a payload")
try:
service.list_transactions(
claims=claims,
request_id="req-kg-live-deny-arg-constraint",
account_slug=None,
page=1,
page_size=10_000,
window_days=31,
status="completed",
side=None,
protocol="mcp",
)
except PolicyDeniedError:
pass
else:
raise RuntimeError("expected qonto-assistant to emit a deny for oversized page_size")
if len(events) < 2:
raise RuntimeError(f"expected allow and deny audit events, got {len(events)}")
observations = tuple(
observation_from_audit_event(
event,
subject_id=genome.subject_id,
capability_scope="finance.qonto.read",
identity_binding="self_asserted",
egress_destination="qonto-thirdparty-api",
genome=genome,
)
for event in events
)
mapping_notes, corrections = _confirm_mapping(events, observations)
return LiveQontoCapture(
events=tuple(events),
observations=observations,
mapping_notes=mapping_notes,
corrections_for_source=corrections,
)
def observations_from_jsonl(
path: Path,
genome: SecurityGenome,
*,
capability_scope: str,
identity_binding: str,
egress_destination: str | None,
) -> tuple[ImmuneObservation, ...]:
events = _read_jsonl(path)
return tuple(
observation_from_audit_event(
event,
subject_id=genome.subject_id,
capability_scope=capability_scope,
identity_binding=identity_binding,
egress_destination=egress_destination,
genome=genome,
)
for event in events
)
def _confirm_mapping(
events: Sequence[Mapping[str, Any]],
observations: Sequence[ImmuneObservation],
) -> tuple[tuple[str, ...], tuple[str, ...]]:
notes: list[str] = []
corrections: list[str] = []
required = (
"request_id",
"timestamp",
"actor",
"tenant_id",
"capability",
"protocol",
"decision",
"policy_version",
"latency_ms",
)
for event, observation in zip(events, observations, strict=True):
missing = [field for field in required if field not in event]
if missing:
corrections.append(f"audit event missing fields: {missing}")
continue
if observation.observation_id != str(event["request_id"]):
corrections.append("request_id mapping drifted")
if observation.resource_scope != str(event["capability"]):
corrections.append("capability->resource_scope mapping drifted")
if observation.capability != "finance.qonto.read":
corrections.append("coarse capability_scope mapping drifted")
if observation.actor_id != str(event["actor"]):
corrections.append("actor mapping drifted")
if observation.decision.value != str(event["decision"]):
corrections.append("decision mapping drifted")
notes.append(
f"{event['request_id']}: decision={event['decision']} "
f"capability={event['capability']} protocol={event['protocol']} "
f"maps to observation {observation.observation_id} "
f"event_class={observation.event_class} "
f"evidence_class={observation.evidence_class.value}"
)
if "identity_binding" not in event:
corrections.append(
"AuditEvent still omits identity_binding; kings-guard continues "
"to use the genome-declared constant self_asserted"
)
if "egress_destination" not in event:
corrections.append(
"AuditEvent still omits egress_destination; kings-guard continues "
"to use the genome-declared constant qonto-thirdparty-api"
)
# Deduplicate repeated correction text across events.
unique_corrections = tuple(dict.fromkeys(corrections))
return tuple(notes), unique_corrections
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
for line in path.read_text(encoding="utf-8").splitlines():
text = line.strip()
if not text:
continue
events.append(json.loads(text))
return events

View file

@ -5,7 +5,8 @@ import json
from kings_guard.adapters import observation_from_audit_event
from kings_guard.contracts import as_jsonable
from kings_guard.fixtures import load_qonto_assistant_pilot
from kings_guard.fixtures import load_pilot_cadence, load_qonto_assistant_pilot
from kings_guard.live import capture_qonto_assistant_events, qonto_assistant_available
from kings_guard.posture import PostureEvaluator
@ -17,32 +18,61 @@ def main() -> None:
choices=["qonto-assistant"],
help="Pilot bundle to evaluate.",
)
parser.add_argument(
"--live",
action="store_true",
help="Observe real events emitted by qonto-assistant's AuditLogger (adjacent checkout).",
)
args = parser.parse_args()
if args.pilot != "qonto-assistant":
raise SystemExit(f"Unsupported pilot: {args.pilot}")
fixture = load_qonto_assistant_pilot()
observation = observation_from_audit_event(
fixture.audit_event,
subject_id=fixture.normalization_hints["subject_id"],
capability_scope=fixture.normalization_hints["capability_scope"],
identity_binding=fixture.normalization_hints["identity_binding"],
egress_destination=fixture.normalization_hints["egress_destination"],
)
evaluation = PostureEvaluator().evaluate(fixture.genome, observation)
print(
json.dumps(
{
"pilot": args.pilot,
"source_notes": list(fixture.source_notes),
"observation": as_jsonable(observation),
"evaluation": as_jsonable(evaluation),
},
indent=2,
sort_keys=True,
cadence = load_pilot_cadence()
evaluator = PostureEvaluator()
if args.live:
if not qonto_assistant_available():
raise SystemExit("qonto-assistant checkout is not available for live observation")
capture = capture_qonto_assistant_events(fixture.genome)
observation = capture.observations[-1]
now = observation.timestamp
evaluation = evaluator.evaluate_with_stream(
fixture.genome,
observation,
cadence,
now=now,
observations=capture.observations,
)
)
payload = {
"pilot": args.pilot,
"mode": "live",
"source_notes": list(fixture.source_notes),
"mapping_notes": list(capture.mapping_notes),
"corrections_for_source": list(capture.corrections_for_source),
"observation": as_jsonable(observation),
"evaluation": as_jsonable(evaluation),
}
else:
observation = observation_from_audit_event(
fixture.audit_event,
subject_id=fixture.normalization_hints["subject_id"],
capability_scope=fixture.normalization_hints["capability_scope"],
identity_binding=fixture.normalization_hints["identity_binding"],
egress_destination=fixture.normalization_hints["egress_destination"],
genome=fixture.genome,
)
evaluation = evaluator.evaluate(fixture.genome, observation)
payload = {
"pilot": args.pilot,
"mode": "fixture",
"source_notes": list(fixture.source_notes),
"observation": as_jsonable(observation),
"evaluation": as_jsonable(evaluation),
}
print(json.dumps(payload, indent=2, sort_keys=True))
if __name__ == "__main__":

View file

@ -1,16 +1,26 @@
from __future__ import annotations
from collections.abc import Sequence
from kings_guard.cadence import EmissionCadence
from kings_guard.contracts import (
STREAM_FINDING_PREFIX,
EffectorRequest,
ImmuneObservation,
ImmuneSignal,
PostureAssessment,
PostureEvaluation,
PostureLevel,
ReconciliationView,
RestrictiveDirection,
SecurityGenome,
SecurityPhenotype,
SignalKind,
StreamAssessment,
StreamCompleteness,
StreamHeartbeat,
)
from kings_guard.stream import evaluate_stream as assess_stream
CRITICAL_FINDINGS = frozenset(
{
@ -39,13 +49,70 @@ RISK_WEIGHTS = {
"control_plane_error": 20,
}
UNKNOWN_STREAM_REASON = (
"per-observation evaluation cannot vouch for stream completeness; "
"the record in hand was scored for richness only"
)
class PostureEvaluator:
def evaluate(self, genome: SecurityGenome, observation: ImmuneObservation) -> PostureEvaluation:
def evaluate(
self,
genome: SecurityGenome,
observation: ImmuneObservation,
*,
stream: StreamAssessment | None = None,
) -> PostureEvaluation:
phenotype = self._derive_phenotype(genome, observation)
assessment = self._assess(phenotype, observation)
signals = self._build_signals(observation, assessment)
return PostureEvaluation(phenotype=phenotype, assessment=assessment, signals=signals)
assessment = self._assess(phenotype, observation, stream=stream)
signals = self._build_signals(observation, assessment, stream=stream)
return PostureEvaluation(
phenotype=phenotype,
assessment=assessment,
signals=signals,
stream=stream,
)
def evaluate_stream(
self,
observations: Sequence[ImmuneObservation],
cadence: EmissionCadence,
*,
now: str,
watching_since: str | None = None,
heartbeats: Sequence[StreamHeartbeat] = (),
reconciliation: ReconciliationView | None = None,
) -> StreamAssessment:
return assess_stream(
observations,
cadence,
now=now,
watching_since=watching_since,
heartbeats=heartbeats,
reconciliation=reconciliation,
)
def evaluate_with_stream(
self,
genome: SecurityGenome,
observation: ImmuneObservation,
cadence: EmissionCadence,
*,
now: str,
observations: Sequence[ImmuneObservation] | None = None,
watching_since: str | None = None,
heartbeats: Sequence[StreamHeartbeat] = (),
reconciliation: ReconciliationView | None = None,
) -> PostureEvaluation:
stream = self.evaluate_stream(
observations if observations is not None else (observation,),
cadence,
now=now,
watching_since=watching_since,
heartbeats=heartbeats,
reconciliation=reconciliation,
)
return self.evaluate(genome, observation, stream=stream)
def _derive_phenotype(
self,
@ -55,6 +122,17 @@ class PostureEvaluator:
findings: list[str] = []
tolerated: list[str] = []
if observation.decision is observation.decision.HEARTBEAT:
return SecurityPhenotype(
subject_id=observation.subject_id,
tenant_id=observation.tenant_id,
observed_capability=observation.capability,
protocol=observation.protocol,
decision=observation.decision,
active_findings=(),
tolerated_findings=(),
)
if observation.tenant_id != genome.tenant_id:
findings.append("tenant_mismatch")
if observation.capability not in genome.permitted_capabilities:
@ -95,6 +173,8 @@ class PostureEvaluator:
self,
phenotype: SecurityPhenotype,
observation: ImmuneObservation,
*,
stream: StreamAssessment | None,
) -> PostureAssessment:
findings = set(phenotype.active_findings)
@ -115,6 +195,7 @@ class PostureEvaluator:
risk_score = max(risk_score, 95)
risk_score = min(risk_score, 100)
# Richness of the record received — never of the stream.
confidence_score = 70
if observation.policy_version is not None:
confidence_score += 10
@ -124,15 +205,26 @@ class PostureEvaluator:
confidence_score += 5
confidence_score = min(confidence_score, 95)
if stream is None:
completeness = StreamCompleteness.UNKNOWN
completeness_reason = UNKNOWN_STREAM_REASON
else:
completeness = stream.completeness
completeness_reason = stream.reason
rationale = _build_rationale(
posture=posture,
findings=phenotype.active_findings,
tolerated_findings=phenotype.tolerated_findings,
completeness=completeness,
completeness_reason=completeness_reason,
)
return PostureAssessment(
posture=posture,
risk_score=risk_score,
confidence_score=confidence_score,
stream_completeness=completeness,
completeness_reason=completeness_reason,
findings=phenotype.active_findings,
tolerated_findings=phenotype.tolerated_findings,
rationale=rationale,
@ -142,23 +234,47 @@ class PostureEvaluator:
self,
observation: ImmuneObservation,
assessment: PostureAssessment,
*,
stream: StreamAssessment | None,
) -> tuple[ImmuneSignal, ...]:
if assessment.posture is PostureLevel.HEALTHY:
return ()
signals: list[ImmuneSignal] = []
if assessment.posture is not PostureLevel.HEALTHY:
if observation.source_system == "qonto-assistant":
signals.append(self._build_qonto_pilot_signal(observation, assessment))
else:
signals.append(
ImmuneSignal(
signal_id=f"sig:{observation.observation_id}",
signal_kind=SignalKind.OBSERVATION_ALERT,
posture=assessment.posture,
summary=assessment.rationale,
target_system=observation.source_system,
findings=assessment.findings,
metadata={"source_system": observation.source_system},
)
)
if stream is not None and stream.findings:
signals.append(self._build_stream_signal(observation, assessment, stream))
return tuple(signals)
if observation.source_system == "qonto-assistant":
return (self._build_qonto_pilot_signal(observation, assessment),)
signal = ImmuneSignal(
signal_id=f"sig:{observation.observation_id}",
signal_kind=SignalKind.OBSERVATION_ALERT,
def _build_stream_signal(
self,
observation: ImmuneObservation,
assessment: PostureAssessment,
stream: StreamAssessment,
) -> ImmuneSignal:
return ImmuneSignal(
signal_id=f"sig:stream:{observation.observation_id}",
signal_kind=SignalKind.STREAM_COMPLETENESS,
posture=assessment.posture,
summary=assessment.rationale,
summary=stream.reason,
target_system=observation.source_system,
findings=assessment.findings,
metadata={"source_system": observation.source_system},
findings=stream.findings,
metadata={
"stream_completeness": stream.completeness.value,
"finding_class": "stream",
},
)
return (signal,)
def _build_qonto_pilot_signal(
self,
@ -167,19 +283,22 @@ class PostureEvaluator:
) -> ImmuneSignal:
if "credential_exfil_probe" in assessment.findings:
action = "lock_actor_temporarily"
direction: RestrictiveDirection = "reduce_authority"
reason = (
"Observed a credential-exfil deny signal; qonto-assistant should activate "
"its fast local loop lockout and preserve metadata-only evidence."
)
else:
action = "tighten_actor_scrutiny"
direction = "require_step_up"
reason = (
"Observed repeated policy-boundary pressure; qonto-assistant should tighten "
"local scrutiny without delegating final authorization to kings-guard."
)
signal_id = f"sig:{observation.observation_id}"
return ImmuneSignal(
signal_id=f"sig:{observation.observation_id}",
signal_id=signal_id,
signal_kind=SignalKind.POSTURE_HINT,
posture=assessment.posture,
summary=reason,
@ -192,6 +311,10 @@ class PostureEvaluator:
authority_boundary="advisory_only",
reason=reason,
requires_human_approval=False,
originating_observation_id=observation.observation_id,
originating_signal_id=signal_id,
stream_completeness=assessment.stream_completeness,
restrictive_direction=direction,
),
EffectorRequest(
target_system="state-hub",
@ -199,11 +322,16 @@ class PostureEvaluator:
authority_boundary="metadata_only",
reason="Preserve posture evidence without copying secret values.",
requires_human_approval=False,
originating_observation_id=observation.observation_id,
originating_signal_id=signal_id,
stream_completeness=assessment.stream_completeness,
restrictive_direction="none",
),
),
metadata={
"pilot_lane": "qonto-assistant",
"resource_scope": observation.resource_scope or "unknown",
"originating_observation_id": observation.observation_id,
},
)
@ -224,19 +352,40 @@ def _build_rationale(
posture: PostureLevel,
findings: tuple[str, ...],
tolerated_findings: tuple[str, ...],
completeness: StreamCompleteness,
completeness_reason: str,
) -> str:
if posture is PostureLevel.HEALTHY:
if tolerated_findings:
return (
content = (
"Healthy posture with tolerated deviations only: "
+ ", ".join(tolerated_findings)
)
return "Healthy posture: observation is compatible with declared intent."
else:
content = "Healthy posture: observation is compatible with declared intent."
else:
detail = ", ".join(findings) if findings else "no active findings"
tolerated = (
f" Tolerated deviations still present: {', '.join(tolerated_findings)}."
if tolerated_findings
else ""
)
content = f"{posture.value.title()} posture driven by {detail}.{tolerated}"
detail = ", ".join(findings) if findings else "no active findings"
tolerated = (
f" Tolerated deviations still present: {', '.join(tolerated_findings)}."
if tolerated_findings
else ""
)
return f"{posture.value.title()} posture driven by {detail}.{tolerated}"
if completeness is StreamCompleteness.COMPLETE:
stream_text = "Stream completeness is complete."
elif completeness is StreamCompleteness.DEGRADED:
stream_text = (
"This judgment rests on a stream I cannot vouch for "
f"({completeness_reason})."
)
else:
stream_text = (
"Stream completeness is unknown; confidence scores the record, "
f"not the stream ({completeness_reason})."
)
return f"{content} {stream_text}"
def is_stream_finding(finding: str) -> bool:
return finding.startswith(STREAM_FINDING_PREFIX)

152
src/kings_guard/stream.py Normal file
View file

@ -0,0 +1,152 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import UTC, datetime
from kings_guard.cadence import EmissionCadence, parse_timestamp
from kings_guard.contracts import (
STREAM_FINDING_PREFIX,
ImmuneObservation,
ReconciliationView,
StreamAssessment,
StreamCompleteness,
StreamHeartbeat,
)
def evaluate_stream(
observations: Sequence[ImmuneObservation],
cadence: EmissionCadence,
*,
now: str | datetime,
watching_since: str | datetime | None = None,
heartbeats: Sequence[StreamHeartbeat] = (),
reconciliation: ReconciliationView | None = None,
) -> StreamAssessment:
"""Evaluate the stream, not its contents.
Findings are prefixed with `stream:` so they cannot be mistaken for
content findings such as `credential_exfil_probe`.
"""
now_dt = _as_datetime(now)
findings: list[str] = []
reasons: list[str] = []
observed_counts = _count_observations(observations)
watch_start = _as_datetime(watching_since) if watching_since is not None else None
if watch_start is None:
timestamps = [parse_timestamp(item.timestamp) for item in observations]
timestamps.extend(parse_timestamp(item.timestamp) for item in heartbeats)
watch_start = min(timestamps) if timestamps else now_dt
for rate in cadence.rates:
window_start = now_dt - rate.window
count = sum(
1
for item in observations
if item.event_class == rate.event_class
and parse_timestamp(item.timestamp) >= window_start
)
observed_counts[rate.event_class] = count
watched_long_enough = (now_dt - watch_start) >= rate.window
if watched_long_enough and count < rate.expected_min:
findings.append(f"{STREAM_FINDING_PREFIX}cadence_unmet:{rate.event_class}")
reasons.append(
f"declared rate for {rate.event_class} is at least {rate.expected_min} "
f"per {int(rate.window.total_seconds())}s; observed {count}"
)
for spec in cadence.heartbeats:
due = (now_dt - watch_start) >= spec.interval
latest = _latest_heartbeat(heartbeats, spec.event_class)
missing = False
if latest is None:
missing = due
else:
missing = (now_dt - parse_timestamp(latest.timestamp)) > spec.interval
if missing:
findings.append(f"{STREAM_FINDING_PREFIX}heartbeat_missing:{spec.covered_event_class}")
reasons.append(
f"no {spec.assertion} heartbeat for {spec.covered_event_class} "
f"within {int(spec.interval.total_seconds())}s"
)
elif not due and latest is None:
reasons.append(
f"heartbeat for {spec.covered_event_class} is not yet due; "
"the stream cannot be vouched for until a positive claim arrives"
)
if cadence.reconciliations:
if reconciliation is None:
reasons.append(
"reconciliation view was not supplied; divergence cannot be ruled out"
)
else:
for spec in cadence.reconciliations:
source_count = int(reconciliation.source_counts.get(spec.covered_event_class, 0))
evidence_count = int(
reconciliation.evidence_counts.get(spec.covered_event_class, 0)
)
if evidence_count < source_count:
findings.append(
f"{STREAM_FINDING_PREFIX}reconciliation_divergence:{spec.covered_event_class}"
)
reasons.append(
f"{spec.covered_event_class} source transitions={source_count} "
f"but evidence count={evidence_count}"
)
completeness = _completeness(findings, reasons, cadence)
reason = (
"; ".join(reasons)
if reasons
else "declared cadence is met and no stream finding is open"
)
return StreamAssessment(
completeness=completeness,
reason=reason,
findings=tuple(findings),
observed_counts=observed_counts,
window_end=now_dt.isoformat().replace("+00:00", "Z"),
)
def _completeness(
findings: Sequence[str],
reasons: Sequence[str],
cadence: EmissionCadence,
) -> StreamCompleteness:
if findings:
return StreamCompleteness.DEGRADED
heartbeat_pending = any("not yet due" in item for item in reasons)
reconciliation_unsupplied = any("was not supplied" in item for item in reasons)
if heartbeat_pending or reconciliation_unsupplied:
return StreamCompleteness.UNKNOWN
if not cadence.heartbeats and not cadence.rates and not cadence.reconciliations:
return StreamCompleteness.UNKNOWN
return StreamCompleteness.COMPLETE
def _count_observations(observations: Sequence[ImmuneObservation]) -> dict[str, int]:
counts: dict[str, int] = {}
for item in observations:
counts[item.event_class] = counts.get(item.event_class, 0) + 1
return counts
def _latest_heartbeat(
heartbeats: Sequence[StreamHeartbeat],
event_class: str,
) -> StreamHeartbeat | None:
matching = [item for item in heartbeats if item.event_class == event_class]
if not matching:
return None
return max(matching, key=lambda item: parse_timestamp(item.timestamp))
def _as_datetime(value: str | datetime) -> datetime:
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
return parse_timestamp(value)