Admit source evidence snapshots and harden stream completeness
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06e89-93a2-7aa2-82b3-ce5ccd2682e6
This commit is contained in:
parent
824fb1b966
commit
31e9963933
34 changed files with 1057 additions and 122 deletions
|
|
@ -50,8 +50,16 @@ def observation_from_audit_event(
|
|||
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,
|
||||
identity_binding=(
|
||||
_optional_str(event.get("identity_binding"))
|
||||
if "identity_binding" in event
|
||||
else identity_binding
|
||||
),
|
||||
egress_destination=(
|
||||
_optional_str(event.get("egress_destination"))
|
||||
if "egress_destination" in event
|
||||
else egress_destination
|
||||
),
|
||||
latency_ms=_optional_int(event.get("latency_ms")),
|
||||
result_count=_optional_int(event.get("result_count")),
|
||||
policy_version=_optional_int(event.get("policy_version")),
|
||||
|
|
|
|||
125
src/kings_guard/adapters/secrets_engine.py
Normal file
125
src/kings_guard/adapters/secrets_engine.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Pure admission of the Engine's non-secret secret-use snapshot envelope.
|
||||
|
||||
Callers supply an Engine response and explicit catalog-to-tenant bindings.
|
||||
A snapshot is not an authorization event or proof of complete observation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from kings_guard.cadence import parse_interval, parse_timestamp
|
||||
from kings_guard.contracts import StreamCompleteness
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SecretUseLane:
|
||||
catalog_id: str
|
||||
tenant_id: str
|
||||
subject_id: str
|
||||
stage: str
|
||||
kind: str
|
||||
ready: bool | None
|
||||
revocation_attempted: bool | None
|
||||
revocation_succeeded: bool | None
|
||||
lifecycle_operation: str | None
|
||||
evidence_kind: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SecretUseSnapshot:
|
||||
as_of: str
|
||||
lanes: tuple[SecretUseLane, ...]
|
||||
heartbeat_interval: timedelta
|
||||
findings: tuple[str, ...]
|
||||
completeness: StreamCompleteness = StreamCompleteness.UNKNOWN
|
||||
source_system: str = "secrets-engine"
|
||||
|
||||
|
||||
def snapshot_from_engine(
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
lane_bindings: Mapping[str, tuple[str, str]],
|
||||
now: str,
|
||||
) -> SecretUseSnapshot:
|
||||
"""Admit scoped snapshot metadata; freshness never asserts event recency.
|
||||
|
||||
Bindings are caller-owned (tenant_id, subject_id), never inferred from paths.
|
||||
Omitted evidence stays None. The response's decision id, session handle,
|
||||
mount, path and arbitrary extra fields are deliberately not retained.
|
||||
"""
|
||||
if payload.get("surface") != "secret-use-evidence":
|
||||
raise ValueError("unsupported Engine snapshot surface")
|
||||
if payload.get("completeness_claimed") is not False:
|
||||
raise ValueError("secret-use snapshot must explicitly deny completeness")
|
||||
as_of = _text(payload, "as_of")
|
||||
age = parse_timestamp(now) - parse_timestamp(as_of)
|
||||
cadence = payload.get("cadence")
|
||||
if not isinstance(cadence, Mapping) or cadence.get("form") != "heartbeat":
|
||||
raise ValueError("expected source-declared heartbeat cadence")
|
||||
interval = parse_interval(_text(cadence, "interval"))
|
||||
if interval <= timedelta(0):
|
||||
raise ValueError("heartbeat interval must be positive")
|
||||
raw_lanes = payload.get("lanes")
|
||||
if not isinstance(raw_lanes, list):
|
||||
raise ValueError("snapshot lanes must be a list")
|
||||
lanes: list[SecretUseLane] = []
|
||||
seen: set[str] = set()
|
||||
for row in raw_lanes:
|
||||
if not isinstance(row, Mapping):
|
||||
raise ValueError("snapshot lane must be an object")
|
||||
catalog_id = _text(row, "catalog_id")
|
||||
if catalog_id in seen or catalog_id not in lane_bindings:
|
||||
raise ValueError("duplicate or unbound catalog lane")
|
||||
seen.add(catalog_id)
|
||||
binding = lane_bindings[catalog_id]
|
||||
if (not isinstance(binding, tuple) or len(binding) != 2
|
||||
or any(not isinstance(value, str) or not value.strip() for value in binding)):
|
||||
raise ValueError("each lane needs explicit tenant and subject scope")
|
||||
if parse_timestamp(_text(row, "as_of")) != parse_timestamp(as_of):
|
||||
raise ValueError("lane timestamp differs from the snapshot envelope")
|
||||
operation = _optional_text(row, "lifecycle_operation")
|
||||
if operation not in {None, "suspend", "deactivate", "destroy", "revoke"}:
|
||||
raise ValueError("unsupported lifecycle operation")
|
||||
evidence_kind = _optional_text(row, "evidence_kind")
|
||||
if evidence_kind not in {None, "attributive", "load-bearing", "heartbeat"}:
|
||||
raise ValueError("unsupported evidence kind")
|
||||
lanes.append(SecretUseLane(
|
||||
catalog_id=catalog_id,
|
||||
tenant_id=binding[0],
|
||||
subject_id=binding[1],
|
||||
stage=_text(row, "stage"),
|
||||
kind=_text(row, "kind"),
|
||||
ready=_optional_bool(row, "ready"),
|
||||
revocation_attempted=_optional_bool(row, "revocation_attempted"),
|
||||
revocation_succeeded=_optional_bool(row, "revocation_succeeded"),
|
||||
lifecycle_operation=operation,
|
||||
evidence_kind=evidence_kind,
|
||||
))
|
||||
findings: list[str] = []
|
||||
if age < timedelta(0):
|
||||
findings.append("snapshot:future_timestamp")
|
||||
elif age > interval:
|
||||
findings.append("snapshot:stale")
|
||||
return SecretUseSnapshot(as_of, tuple(lanes), interval, tuple(findings))
|
||||
|
||||
|
||||
def _text(payload: Mapping[str, Any], key: str) -> str:
|
||||
value = payload.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"{key} must be a nonempty string")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_text(payload: Mapping[str, Any], key: str) -> str | None:
|
||||
return _text(payload, key) if key in payload else None
|
||||
|
||||
|
||||
def _optional_bool(payload: Mapping[str, Any], key: str) -> bool | None:
|
||||
if key not in payload:
|
||||
return None
|
||||
if type(payload[key]) is not bool:
|
||||
raise ValueError(f"{key} must be a boolean when supplied")
|
||||
return payload[key]
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
|
@ -32,15 +33,10 @@ def parse_interval(value: str | int) -> timedelta:
|
|||
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]))
|
||||
match = re.fullmatch(r"p(?:(\d+)d)?(?:t(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?)?", text)
|
||||
if match and any(match.groups()):
|
||||
days, hours, minutes, seconds = (int(value or 0) for value in match.groups())
|
||||
return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
|
||||
raise ValueError(f"unsupported interval: {value!r}")
|
||||
|
||||
|
||||
|
|
@ -71,10 +67,10 @@ class ReconciliationCadence:
|
|||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmissionCadence:
|
||||
"""Runtime view of the Taxonomy draft, loaded from the local worked example.
|
||||
"""Runtime projection of InfoTechCanon standard/emission-cadence 0.1.
|
||||
|
||||
This is a consumer of the draft in `specs/EmissionCadenceDeclaration.md`,
|
||||
not a competing schema. Ownership stays with Taxonomy.
|
||||
NetKingdom classifications and local provenance live in extensions.
|
||||
Canonical schema validation belongs to the owner's schema, not a local copy.
|
||||
"""
|
||||
|
||||
schema_version: str
|
||||
|
|
@ -106,12 +102,27 @@ def load_qonto_assistant_cadence() -> EmissionCadence:
|
|||
return emission_cadence_from_dict(payload)
|
||||
|
||||
|
||||
def load_qonto_assistant_source_cadence() -> EmissionCadence:
|
||||
"""Load qonto-assistant's shipped source-owned emission declaration."""
|
||||
payload = json.loads(
|
||||
files("kings_guard")
|
||||
.joinpath("fixtures")
|
||||
.joinpath("qonto_assistant_source_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"]))
|
||||
if data.get("schema_version") != "0.1" or not data.get("declaration_id"):
|
||||
raise ValueError("expected canonical emission-cadence 0.1 declaration")
|
||||
provenance = data.get("extensions", {}).get("kings-guard", {})
|
||||
for item in data["sources"]:
|
||||
profile = item.get("extensions", {}).get("net-kingdom", {})
|
||||
evidence_class = EvidenceClass(str(profile["evidence_class"]))
|
||||
form = str(item["form"])
|
||||
if form == "expected-rate":
|
||||
rates.append(
|
||||
|
|
@ -150,11 +161,11 @@ def emission_cadence_from_dict(data: Mapping[str, Any]) -> EmissionCadence:
|
|||
)
|
||||
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")),
|
||||
status=str(provenance.get("status", "source-declared")),
|
||||
drafter=str(provenance.get("drafter", data["source"])),
|
||||
owner=str(provenance.get("owner", data["source"])),
|
||||
source_system=str(data.get("source", data.get("source_system", "unknown"))),
|
||||
reference_instance=str(data.get("reference_instance", "GH-WP-0002-T04")),
|
||||
reference_instance=str(provenance.get("reference_instance", data["declaration_id"])),
|
||||
rates=tuple(rates),
|
||||
heartbeats=tuple(heartbeats),
|
||||
reconciliations=tuple(reconciliations),
|
||||
|
|
|
|||
|
|
@ -140,12 +140,9 @@ class SecurityGenome:
|
|||
permitted_protocols=frozenset(str(item) for item in data["permitted_protocols"]),
|
||||
permitted_egress=frozenset(str(item) for item in data["permitted_egress"]),
|
||||
data_classifications=tuple(str(item) for item in data.get("data_classifications", ())),
|
||||
tolerances=tuple(
|
||||
ToleranceRule.from_dict(item) for item in data.get("tolerances", ())
|
||||
),
|
||||
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", ())
|
||||
DeclaredEvidenceSource.from_dict(item) for item in data.get("evidence_sources", ())
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,35 +1,31 @@
|
|||
{
|
||||
"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."
|
||||
"extensions": {
|
||||
"net-kingdom": {
|
||||
"evidence_class": "attributive"
|
||||
},
|
||||
"kings-guard": {
|
||||
"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"
|
||||
|
|
@ -38,7 +34,24 @@
|
|||
"compare_local": "source_transition_counts.audit.deny",
|
||||
"compare_observed": "evidence_counts.audit.deny",
|
||||
"divergence": "finding"
|
||||
},
|
||||
"extensions": {
|
||||
"net-kingdom": {
|
||||
"evidence_class": "load-bearing",
|
||||
"rate_monitoring": "forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"declaration_id": "kings-guard.qonto-worked-example",
|
||||
"extensions": {
|
||||
"kings-guard": {
|
||||
"status": "taxonomy-draft",
|
||||
"drafter": "kings-guard",
|
||||
"owner": "Taxonomy",
|
||||
"belongs_alongside": "security_genome",
|
||||
"reference_instance": "GH-WP-0002-T04",
|
||||
"reference_source_declaration": "approval-engine/cadence.yaml"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
38
src/kings_guard/fixtures/qonto_assistant_source_cadence.json
Normal file
38
src/kings_guard/fixtures/qonto_assistant_source_cadence.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"source": "qonto-assistant",
|
||||
"sources": [
|
||||
{
|
||||
"source_id": "qonto-assistant.audit.deny",
|
||||
"source_system": "qonto-assistant",
|
||||
"event_class": "audit.deny",
|
||||
"form": "heartbeat-or-reconciliation",
|
||||
"heartbeat": {
|
||||
"event_class": "audit.heartbeat",
|
||||
"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"
|
||||
},
|
||||
"extensions": {
|
||||
"net-kingdom": {
|
||||
"evidence_class": "load-bearing",
|
||||
"rate_monitoring": "forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"declaration_id": "qonto-assistant.audit-source-cadence",
|
||||
"extensions": {
|
||||
"kings-guard": {
|
||||
"status": "source-declared",
|
||||
"drafter": "qonto-assistant",
|
||||
"owner": "qonto-assistant",
|
||||
"reference_instance": "QONTO-WP-0005"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,12 +8,15 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
from kings_guard.adapters import observation_from_audit_event
|
||||
from kings_guard.contracts import ImmuneObservation, SecurityGenome
|
||||
from kings_guard.contracts import (
|
||||
ImmuneObservation,
|
||||
ReconciliationView,
|
||||
SecurityGenome,
|
||||
StreamHeartbeat,
|
||||
)
|
||||
|
||||
QONTO_ASSISTANT_SRC = Path("/home/worsch/qonto-assistant/src")
|
||||
QONTO_POLICY = (
|
||||
Path("/home/worsch/qonto-assistant/src/qonto_assistant/policy/qonto-v1.yaml")
|
||||
)
|
||||
QONTO_POLICY = Path("/home/worsch/qonto-assistant/src/qonto_assistant/policy/qonto-v1.yaml")
|
||||
QONTO_FIXTURES = Path("/home/worsch/qonto-assistant/tests/fixtures/qonto")
|
||||
|
||||
|
||||
|
|
@ -21,8 +24,12 @@ QONTO_FIXTURES = Path("/home/worsch/qonto-assistant/tests/fixtures/qonto")
|
|||
class LiveQontoCapture:
|
||||
events: tuple[dict[str, Any], ...]
|
||||
observations: tuple[ImmuneObservation, ...]
|
||||
heartbeats: tuple[StreamHeartbeat, ...]
|
||||
reconciliation: ReconciliationView
|
||||
mapping_notes: tuple[str, ...]
|
||||
corrections_for_source: tuple[str, ...]
|
||||
stream_notes: tuple[str, ...]
|
||||
stream_corrections: tuple[str, ...]
|
||||
|
||||
|
||||
def qonto_assistant_available() -> bool:
|
||||
|
|
@ -36,9 +43,7 @@ def capture_qonto_assistant_events(genome: SecurityGenome) -> LiveQontoCapture:
|
|||
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}"
|
||||
)
|
||||
raise FileNotFoundError(f"qonto-assistant checkout not found at {QONTO_ASSISTANT_SRC}")
|
||||
|
||||
src = str(QONTO_ASSISTANT_SRC)
|
||||
if src not in sys.path:
|
||||
|
|
@ -53,6 +58,8 @@ def capture_qonto_assistant_events(genome: SecurityGenome) -> LiveQontoCapture:
|
|||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
events: list[dict[str, Any]] = []
|
||||
audit_logger = AuditLogger(sink=events.append)
|
||||
audit_logger.emit_heartbeat(reason="startup")
|
||||
service = CapabilityService(
|
||||
client=FixtureQontoClient(fixture_dir=QONTO_FIXTURES),
|
||||
policy=PolicyEngine.from_file(
|
||||
|
|
@ -60,7 +67,7 @@ def capture_qonto_assistant_events(genome: SecurityGenome) -> LiveQontoCapture:
|
|||
required_scope="finance.qonto.read",
|
||||
enforce_scope=False,
|
||||
),
|
||||
audit_logger=AuditLogger(sink=events.append),
|
||||
audit_logger=audit_logger,
|
||||
rate_limiter=RateLimiter(limit=100, window_seconds=60),
|
||||
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
||||
)
|
||||
|
|
@ -89,8 +96,13 @@ def capture_qonto_assistant_events(genome: SecurityGenome) -> LiveQontoCapture:
|
|||
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)}")
|
||||
audit_logger.emit_heartbeat(reason="periodic")
|
||||
reconciliation_payload = audit_logger.reconciliation_snapshot()
|
||||
request_events = tuple(
|
||||
event for event in events if event.get("event_class") in {"audit.allow", "audit.deny"}
|
||||
)
|
||||
if len(request_events) < 2:
|
||||
raise RuntimeError(f"expected allow and deny audit events, got {len(request_events)}")
|
||||
|
||||
observations = tuple(
|
||||
observation_from_audit_event(
|
||||
|
|
@ -101,14 +113,25 @@ def capture_qonto_assistant_events(genome: SecurityGenome) -> LiveQontoCapture:
|
|||
egress_destination="qonto-thirdparty-api",
|
||||
genome=genome,
|
||||
)
|
||||
for event in events
|
||||
for event in request_events
|
||||
)
|
||||
mapping_notes, corrections = _confirm_mapping(events, observations)
|
||||
mapping_notes, corrections = _confirm_mapping(request_events, observations)
|
||||
heartbeats, reconciliation, stream_notes, stream_corrections = _confirm_stream(
|
||||
events, reconciliation_payload
|
||||
)
|
||||
if corrections or stream_corrections:
|
||||
raise ValueError("source capture failed admission: " + "; ".join(
|
||||
(*corrections, *stream_corrections)
|
||||
))
|
||||
return LiveQontoCapture(
|
||||
events=tuple(events),
|
||||
events=request_events,
|
||||
observations=observations,
|
||||
heartbeats=heartbeats,
|
||||
reconciliation=reconciliation,
|
||||
mapping_notes=mapping_notes,
|
||||
corrections_for_source=corrections,
|
||||
stream_notes=stream_notes,
|
||||
stream_corrections=stream_corrections,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -166,6 +189,14 @@ def _confirm_mapping(
|
|||
corrections.append("actor mapping drifted")
|
||||
if observation.decision.value != str(event["decision"]):
|
||||
corrections.append("decision mapping drifted")
|
||||
if "identity_binding" in event and observation.identity_binding != (
|
||||
event["identity_binding"] or None
|
||||
):
|
||||
corrections.append("identity_binding mapping drifted")
|
||||
if "egress_destination" in event and observation.egress_destination != (
|
||||
event["egress_destination"] or None
|
||||
):
|
||||
corrections.append("egress_destination mapping drifted")
|
||||
notes.append(
|
||||
f"{event['request_id']}: decision={event['decision']} "
|
||||
f"capability={event['capability']} protocol={event['protocol']} "
|
||||
|
|
@ -196,3 +227,95 @@ def _read_jsonl(path: Path) -> list[dict[str, Any]]:
|
|||
continue
|
||||
events.append(json.loads(text))
|
||||
return events
|
||||
|
||||
|
||||
def _confirm_stream(
|
||||
events: Sequence[Mapping[str, Any]],
|
||||
snapshot: Mapping[str, Any],
|
||||
) -> tuple[
|
||||
tuple[StreamHeartbeat, ...],
|
||||
ReconciliationView,
|
||||
tuple[str, ...],
|
||||
tuple[str, ...],
|
||||
]:
|
||||
notes: list[str] = []
|
||||
corrections: list[str] = []
|
||||
if not events:
|
||||
raise ValueError("cannot validate an empty source capture")
|
||||
instance_ids = {str(event.get("stream_instance_id")) for event in events}
|
||||
if len(instance_ids) != 1 or "None" in instance_ids:
|
||||
corrections.append("stream_instance_id is missing or changed within one capture")
|
||||
|
||||
if any(
|
||||
type(event.get("stream_sequence")) is not int or event["stream_sequence"] < 1
|
||||
for event in events
|
||||
):
|
||||
raise ValueError("stream_sequence must be a positive integer")
|
||||
if snapshot.get("stream_instance_id") not in instance_ids:
|
||||
corrections.append("reconciliation stream_instance_id does not match the stream")
|
||||
stream_ids = {event.get("stream_id") for event in events}
|
||||
if len(stream_ids) != 1 or None in stream_ids or snapshot.get("stream_id") not in stream_ids:
|
||||
corrections.append("reconciliation stream_id is missing or does not match the stream")
|
||||
sequences = [event["stream_sequence"] for event in events]
|
||||
expected_sequences = list(range(1, len(events) + 1))
|
||||
if sequences != expected_sequences:
|
||||
corrections.append("stream_sequence is not contiguous from 1 within the instance")
|
||||
if (type(snapshot.get("last_stream_sequence")) is not int
|
||||
or snapshot["last_stream_sequence"] != sequences[-1]):
|
||||
corrections.append("reconciliation last_stream_sequence does not match the stream")
|
||||
|
||||
heartbeat_events = [event for event in events if event.get("event_class") == "audit.heartbeat"]
|
||||
heartbeats = tuple(
|
||||
StreamHeartbeat(
|
||||
source_system="qonto-assistant",
|
||||
timestamp=str(event["timestamp"]),
|
||||
event_class="audit.heartbeat",
|
||||
assertion=str(event["assertion"]),
|
||||
counts=_transition_counts(event),
|
||||
)
|
||||
for event in heartbeat_events
|
||||
)
|
||||
if not heartbeats:
|
||||
corrections.append("source emitted no audit.heartbeat record")
|
||||
running_counts = {"audit.allow": 0, "audit.deny": 0}
|
||||
for event in events:
|
||||
event_class = event.get("event_class")
|
||||
if event_class in running_counts:
|
||||
running_counts[event_class] += 1
|
||||
elif event_class == "audit.heartbeat":
|
||||
if _transition_counts(event) != running_counts:
|
||||
corrections.append("heartbeat counts do not match preceding request events")
|
||||
if event.get("assertion") not in {"nothing-to-report", "transitions-reported"}:
|
||||
corrections.append("heartbeat assertion is not a recognized positive claim")
|
||||
else:
|
||||
corrections.append("source emitted an undeclared event class")
|
||||
|
||||
source_counts = _transition_counts(snapshot)
|
||||
evidence_counts = {
|
||||
event_class: sum(1 for event in events if event.get("event_class") == event_class)
|
||||
for event_class in ("audit.allow", "audit.deny")
|
||||
}
|
||||
reconciliation = ReconciliationView(
|
||||
source_counts=source_counts,
|
||||
evidence_counts=evidence_counts,
|
||||
)
|
||||
if source_counts != evidence_counts:
|
||||
corrections.append("reconciliation source counts do not match captured request events")
|
||||
|
||||
if events:
|
||||
notes.append(
|
||||
f"instance={next(iter(instance_ids))} sequence=1..{sequences[-1]} "
|
||||
f"heartbeats={len(heartbeats)}"
|
||||
)
|
||||
notes.append(f"source_counts={source_counts} evidence_counts={evidence_counts}")
|
||||
return heartbeats, reconciliation, tuple(notes), tuple(dict.fromkeys(corrections))
|
||||
|
||||
|
||||
def _transition_counts(payload: Mapping[str, Any]) -> dict[str, int]:
|
||||
counts = payload.get("source_transition_counts")
|
||||
if not isinstance(counts, Mapping) or any(
|
||||
type(counts.get(key)) is not int or counts[key] < 0
|
||||
for key in ("audit.allow", "audit.deny")
|
||||
):
|
||||
raise ValueError("source transition counts must include nonnegative allow and deny counts")
|
||||
return {key: counts[key] for key in ("audit.allow", "audit.deny")}
|
||||
|
|
|
|||
|
|
@ -146,7 +146,9 @@ class PostureEvaluator:
|
|||
findings.append("unexpected_egress_destination")
|
||||
|
||||
if observation.identity_binding and observation.identity_binding != "verified_token":
|
||||
tolerance = _matching_tolerance(genome, "identity_binding", observation.identity_binding)
|
||||
tolerance = _matching_tolerance(
|
||||
genome, "identity_binding", observation.identity_binding
|
||||
)
|
||||
if tolerance is None:
|
||||
findings.append("unverified_identity_binding")
|
||||
else:
|
||||
|
|
@ -357,9 +359,8 @@ def _build_rationale(
|
|||
) -> str:
|
||||
if posture is PostureLevel.HEALTHY:
|
||||
if tolerated_findings:
|
||||
content = (
|
||||
"Healthy posture with tolerated deviations only: "
|
||||
+ ", ".join(tolerated_findings)
|
||||
content = "Healthy posture with tolerated deviations only: " + ", ".join(
|
||||
tolerated_findings
|
||||
)
|
||||
else:
|
||||
content = "Healthy posture: observation is compatible with declared intent."
|
||||
|
|
@ -375,10 +376,7 @@ def _build_rationale(
|
|||
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})."
|
||||
)
|
||||
stream_text = f"This judgment rests on a stream I cannot vouch for ({completeness_reason})."
|
||||
else:
|
||||
stream_text = (
|
||||
"Stream completeness is unknown; confidence scores the record, "
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@ def evaluate_stream(
|
|||
now_dt = _as_datetime(now)
|
||||
findings: list[str] = []
|
||||
reasons: list[str] = []
|
||||
observations = tuple(
|
||||
item for item in observations
|
||||
if item.source_system == cadence.source_system and parse_timestamp(item.timestamp) <= now_dt
|
||||
)
|
||||
heartbeats = tuple(
|
||||
item for item in heartbeats
|
||||
if item.source_system == cadence.source_system and parse_timestamp(item.timestamp) <= now_dt
|
||||
)
|
||||
observed_counts = _count_observations(observations)
|
||||
|
||||
watch_start = _as_datetime(watching_since) if watching_since is not None else None
|
||||
|
|
@ -49,6 +57,8 @@ def evaluate_stream(
|
|||
)
|
||||
observed_counts[rate.event_class] = count
|
||||
watched_long_enough = (now_dt - watch_start) >= rate.window
|
||||
if not watched_long_enough:
|
||||
reasons.append(f"rate window for {rate.event_class} has not been fully observed")
|
||||
if watched_long_enough and count < rate.expected_min:
|
||||
findings.append(f"{STREAM_FINDING_PREFIX}cadence_unmet:{rate.event_class}")
|
||||
reasons.append(
|
||||
|
|
@ -78,16 +88,19 @@ def evaluate_stream(
|
|||
|
||||
if cadence.reconciliations:
|
||||
if reconciliation is None:
|
||||
reasons.append(
|
||||
"reconciliation view was not supplied; divergence cannot be ruled out"
|
||||
)
|
||||
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:
|
||||
source_count = reconciliation.source_counts.get(spec.covered_event_class)
|
||||
evidence_count = reconciliation.evidence_counts.get(spec.covered_event_class)
|
||||
if any(type(value) is not int or value < 0
|
||||
for value in (source_count, evidence_count)):
|
||||
reasons.append(
|
||||
"valid reconciliation counts were not supplied for "
|
||||
f"{spec.covered_event_class}"
|
||||
)
|
||||
continue
|
||||
if evidence_count != source_count:
|
||||
findings.append(
|
||||
f"{STREAM_FINDING_PREFIX}reconciliation_divergence:{spec.covered_event_class}"
|
||||
)
|
||||
|
|
@ -98,9 +111,7 @@ def evaluate_stream(
|
|||
|
||||
completeness = _completeness(findings, reasons, cadence)
|
||||
reason = (
|
||||
"; ".join(reasons)
|
||||
if reasons
|
||||
else "declared cadence is met and no stream finding is open"
|
||||
"; ".join(reasons) if reasons else "declared cadence is met and no stream finding is open"
|
||||
)
|
||||
return StreamAssessment(
|
||||
completeness=completeness,
|
||||
|
|
@ -118,9 +129,7 @@ def _completeness(
|
|||
) -> 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:
|
||||
if reasons:
|
||||
return StreamCompleteness.UNKNOWN
|
||||
if not cadence.heartbeats and not cadence.rates and not cadence.reconciliations:
|
||||
return StreamCompleteness.UNKNOWN
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue