diff --git a/docs/CadenceObserverAdmission.md b/docs/CadenceObserverAdmission.md new file mode 100644 index 0000000..9000a66 --- /dev/null +++ b/docs/CadenceObserverAdmission.md @@ -0,0 +1,80 @@ +# Canonical cadence observer admission + +NK-WP-0035; KG-WP-0005. The observer now consumes source-authored +`extensions.netkingdom.evidence_class`, matching NetKingdom's profile namespace. +The historical `extensions.net-kingdom` is refused with an explicit migration +diagnostic, including conflicting dual-namespace inputs. Missing/malformed owner +classification is an error; no classifier is inferred from traffic or event names. +The two local copied fixtures use the canonical namespace. They are local +algorithm inputs, not deployment, source approval, or profile-wide adoption. + +The profile remains proposed. Imported contract is InfoTechCanon candidate +document 0.2.0 / wire schema 0.1, bundle `b08b4d95fc4b0bd3`, schema SHA256 +`6d52692eb1e4d1325e0d6062d95acded98beddaae5bcfe24c6f0e91f1b6be6ae`. +Schema validation belongs to the owner artifact; no competing generic schema +is bundled here. The source class inventory stays source-owned. + +## Reuse the evidence engine's delivered interfaces + +AUDIT-WP-0009-T04/T05/T06/T07 are done. Reuse, without duplicating: + +- `POST /v1/events` authenticated envelope; receiver heartbeat type + `audit-core.heartbeat` with asserted covered class at `data.class`. +- Protected `AUDIT_CORE_SENDERS` registry object carries source-owned canonical + `emission_cadence`, plus exact source/tenant/evidence/credential bindings. + `heartbeat_classes` maps covered event class to maximum gap seconds. + The non-secret scope overlay may add/shorten heartbeat bounds but cannot + insert/change `emission_cadence`; platform must provision the protected registry. +- `GET /v1/reconciliation?source=&tenant=&since=&until=` + gives accepted counts, not payloads. Own-source sender read is allowed with + may_read=false; cross-source requests return403. Explicit equal source/tenant + and window boundaries are required for a meaningful local comparison. +- `GET /v1/stream-findings` evaluates missing registered heartbeats and declared + rates. It requires a may_read identity with full tenant scope. It is not a + tenant-filtered observer feed; request an owner-delivered sanitized observation + or separately governed read lane rather than widening a source writer token. + +Approval Engine already publishes receiver-compatible heartbeats and has exact +sender/source `approval-engine`, tenant `tenant:platform`. Its local transition +classes use bare issuance/use/supersession/revocation while accepted event types +are approval.issuance/use/supersession/revocation; owner mapping must preserve +both count coordinates. Requested heartbeat_classes bare classes at172800sec +must be reconciled with emitted interval86400sec and the receiver's1.5grace. +Canonical declaration admission and heartbeat mapping need actual owner receipts; +existing ingestion evidence alone does not prove cadence monitoring was enabled. + +Qonto's source is structured stdout, process-instance scoped with event class +audit.heartbeat and its own source transition/snapshot counters. Its deployment +owner must deliver a bounded capture and snapshot; no audit-core sender/feed is +declared in the reviewed receiver source. If routed through Audit Core, owners +must explicitly translate the heartbeat envelope, bind exact sender+tenant, +retain source instance/sequence/window semantics, and admit its declaration. +An unbounded database accepted count cannot replace a process-local snapshot. + +local-identity has no registered sender or evidence feed, declares no heartbeat, +and remains the owner's explicit activity-scoped incompatibility. Do not accept +reconciliation-only as satisfying NetKingdom's rare-class conjunction or create +an observation from a local TSV file merely to close adoption. + +## Owned operational wait and acceptance + +KG-WP-0005-T03 remains wait/blocked on a runtime-owner supplied authorized Qonto +capture from startup through a request allow/deny, periodic heartbeat at the +declared interval, and same-instance reconciliation. The namespace migration +does not produce that feed. `main --live` still drives the adjacent source's +AuditLogger with a fixture client; it is local emit-path evidence. + +Existing executable seams are `live._confirm_stream` (instance/sequence/count +admission), `cadence.emission_cadence_from_dict` (owner-classified projection), +`stream.evaluate_stream` (heartbeats/reconciliation/observations), and +`posture.evaluate_with_stream`. Feed integration must retain source_id/tenant, +declared heartbeat vs covered-class mapping, time bounds, process instance, +contiguous sequence and durable/local count scope; missing or malformed inputs +remain UNKNOWN or are rejected. A runtime owner returns bounded non-secret +positive/missing-heartbeat/count-divergence plus failed-read receipts. Matching +counts prove neither completeness nor event occurrence; findings detect omission +after the fact. There is no automatic containment authority in this delivery. + +Knowledge orientation reloaded 2026-10-03: Emergent Security0.2.5 verified-files, +package digest `9e186dbd6f2d5eb455bca24db4b03cd866c63c4e94660430dd15f2ca9856215f`, +generic start plus evidence source card. Orientation is not a readiness grant. diff --git a/src/kings_guard/cadence.py b/src/kings_guard/cadence.py index 7c61cb5..1274588 100644 --- a/src/kings_guard/cadence.py +++ b/src/kings_guard/cadence.py @@ -121,8 +121,21 @@ def emission_cadence_from_dict(data: Mapping[str, Any]) -> EmissionCadence: 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"])) + extensions = item.get("extensions", {}) + if not isinstance(extensions, Mapping): + raise ValueError("cadence entry extensions must be an object") + if "net-kingdom" in extensions: + raise ValueError("legacy extensions.net-kingdom must migrate to extensions.netkingdom") + profile = extensions.get("netkingdom") + if not isinstance(profile, Mapping): + raise ValueError("cadence entry requires source-owned extensions.netkingdom") + try: + evidence_class = EvidenceClass(profile["evidence_class"]) + except (KeyError, ValueError, TypeError) as exc: + raise ValueError( + "extensions.netkingdom.evidence_class must be source-declared " + "load-bearing or attributive" + ) from exc form = str(item["form"]) if form == "expected-rate": rates.append( diff --git a/src/kings_guard/fixtures/qonto_assistant_cadence.json b/src/kings_guard/fixtures/qonto_assistant_cadence.json index dd21962..9f7f0ca 100644 --- a/src/kings_guard/fixtures/qonto_assistant_cadence.json +++ b/src/kings_guard/fixtures/qonto_assistant_cadence.json @@ -11,7 +11,7 @@ "expected_min": 1, "drop_below": "finding", "extensions": { - "net-kingdom": { + "netkingdom": { "evidence_class": "attributive" }, "kings-guard": { @@ -36,7 +36,7 @@ "divergence": "finding" }, "extensions": { - "net-kingdom": { + "netkingdom": { "evidence_class": "load-bearing", "rate_monitoring": "forbidden" } diff --git a/src/kings_guard/fixtures/qonto_assistant_source_cadence.json b/src/kings_guard/fixtures/qonto_assistant_source_cadence.json index 3e0e45a..95a333d 100644 --- a/src/kings_guard/fixtures/qonto_assistant_source_cadence.json +++ b/src/kings_guard/fixtures/qonto_assistant_source_cadence.json @@ -19,7 +19,7 @@ "divergence": "finding" }, "extensions": { - "net-kingdom": { + "netkingdom": { "evidence_class": "load-bearing", "rate_monitoring": "forbidden" } diff --git a/tests/test_cadence_declaration.py b/tests/test_cadence_declaration.py index 78e17ba..3b5595a 100644 --- a/tests/test_cadence_declaration.py +++ b/tests/test_cadence_declaration.py @@ -41,25 +41,41 @@ def test_qonto_source_declaration_is_consumed_separately_from_the_taxonomy_draft ) def test_qonto_source_cadence_fixture_matches_the_source_owned_declaration() -> None: yaml = pytest.importorskip("yaml") - source = yaml.safe_load(QONTO_SOURCE_DECLARATION.read_text(encoding="utf-8"))[ - "audit_emission_cadence" - ] + document = yaml.safe_load(QONTO_SOURCE_DECLARATION.read_text(encoding="utf-8")) + source = document.get("audit_emission_cadence", document) cadence = load_qonto_assistant_source_cadence() assert cadence.source_system == source["source"] - assert cadence.heartbeats[0].event_class == source["heartbeat"]["event_class"] - assert ( - cadence.heartbeats[0].interval.total_seconds() - == source["heartbeat"]["default_interval_seconds"] - ) - assert source["event_classes"]["audit.deny"]["form"] in cadence.forms() - assert source["event_classes"]["audit.allow"]["completeness_claimed"] is False + if "sources" in source: + deny = next(item for item in source["sources"] if item["event_class"] == "audit.deny") + heartbeat = deny["heartbeat"] + assert cadence.heartbeats[0].event_class == heartbeat["event_class"] + from kings_guard.cadence import parse_interval + + interval = heartbeat.get("interval_seconds", heartbeat.get("interval")) + assert cadence.heartbeats[0].interval == parse_interval(interval) + assert deny["form"] in cadence.forms() + assert deny["extensions"]["netkingdom"]["evidence_class"] == "load-bearing" + else: + # Historical owner envelope is checked only as provenance during migration. + # The runtime reader accepts the canonical namespace exclusively. + assert cadence.heartbeats[0].event_class == source["heartbeat"]["event_class"] + assert ( + cadence.heartbeats[0].interval.total_seconds() + == source["heartbeat"]["default_interval_seconds"] + ) + assert source["event_classes"]["audit.deny"]["form"] in cadence.forms() + assert source["event_classes"]["audit.allow"]["completeness_claimed"] is False assert cadence.rates == () -@pytest.mark.parametrize("filename", [ - "qonto_assistant_cadence.json", "qonto_assistant_source_cadence.json", -]) +@pytest.mark.parametrize( + "filename", + [ + "qonto_assistant_cadence.json", + "qonto_assistant_source_cadence.json", + ], +) def test_cadence_fixtures_conform_to_owner_schema(filename): import json @@ -81,3 +97,67 @@ def test_canonical_compound_durations_are_consumed(value, seconds): from kings_guard.cadence import parse_interval assert parse_interval(value).total_seconds() == seconds + + +def _canonical_cadence_document(): + import json + + return json.loads( + (ROOT / "src/kings_guard/fixtures/qonto_assistant_source_cadence.json").read_text() + ) + + +def test_canonical_profile_namespace_preserves_source_classification(): + from kings_guard.cadence import emission_cadence_from_dict + from kings_guard.contracts import EvidenceClass + + payload = _canonical_cadence_document() + cadence = emission_cadence_from_dict(payload) + assert cadence.heartbeats[0].evidence_class is EvidenceClass.LOAD_BEARING + payload["sources"][0]["extensions"]["netkingdom"]["evidence_class"] = "attributive" + assert ( + emission_cadence_from_dict(payload).heartbeats[0].evidence_class + is EvidenceClass.ATTRIBUTIVE + ) + + +@pytest.mark.parametrize("retain_canonical", [False, True]) +def test_legacy_profile_namespace_requires_explicit_owner_migration(retain_canonical): + from kings_guard.cadence import emission_cadence_from_dict + + payload = _canonical_cadence_document() + extensions = payload["sources"][0]["extensions"] + extensions["net-kingdom"] = dict(extensions["netkingdom"]) + if not retain_canonical: + del extensions["netkingdom"] + with pytest.raises(ValueError, match="legacy extensions.net-kingdom must migrate"): + emission_cadence_from_dict(payload) + + +@pytest.mark.parametrize("profile", [None, [], "load-bearing"]) +def test_missing_or_malformed_profile_does_not_infer_classification(profile): + from kings_guard.cadence import emission_cadence_from_dict + + payload = _canonical_cadence_document() + payload["sources"][0]["extensions"]["netkingdom"] = profile + with pytest.raises(ValueError, match="requires source-owned extensions.netkingdom"): + emission_cadence_from_dict(payload) + + +@pytest.mark.parametrize("classification", [None, True, "unknown"]) +def test_invalid_evidence_classification_is_refused(classification): + from kings_guard.cadence import emission_cadence_from_dict + + payload = _canonical_cadence_document() + payload["sources"][0]["extensions"]["netkingdom"] = {"evidence_class": classification} + with pytest.raises(ValueError, match="evidence_class must be source-declared"): + emission_cadence_from_dict(payload) + + +def test_malformed_entry_extensions_are_diagnosed(): + from kings_guard.cadence import emission_cadence_from_dict + + payload = _canonical_cadence_document() + payload["sources"][0]["extensions"] = [] + with pytest.raises(ValueError, match="extensions must be an object"): + emission_cadence_from_dict(payload) diff --git a/workplans/KG-WP-0005-qonto-source-cadence-admission.md b/workplans/KG-WP-0005-qonto-source-cadence-admission.md index 894bf1f..16786cf 100644 --- a/workplans/KG-WP-0005-qonto-source-cadence-admission.md +++ b/workplans/KG-WP-0005-qonto-source-cadence-admission.md @@ -8,7 +8,7 @@ status: blocked owner: codex topic_slug: netkingdom created: "2026-09-04" -updated: "2026-09-27" +updated: "2026-10-03" origin: cross-repo origin_ref: QONTO-WP-0005 state_hub_workstream_id: "db9c1dfb-cab3-5887-963a-bfcdf01cff69" @@ -84,3 +84,24 @@ has no route to produce itself — AGENTS.md forbids opening a direct client to a Tooling-layer system or deployment to obtain it. Workplan moved from `active` to `blocked` pending that external input; no further action is possible here until it arrives. + + +## Align the observer with the NetKingdom profile namespace + +```task +id: KG-WP-0005-T04 +status: done +priority: high +``` + +2026-10-03: migrated the parser and both copied cadence fixtures to source-owned +extensions.netkingdom. Legacy extensions.net-kingdom, conflicting dual namespace, +missing/malformed profile or invalid classification receive explicit rejection +diagnostics; classifications are never inferred. Tests retain generic owner-schema +checks and source fixture-only evidence. See docs/CadenceObserverAdmission.md. + +T03 remains the owned operational wait: require runtime-owner deployed startup, +allow/deny, periodic heartbeat and same-instance snapshot plus actual receiver +read/mapping receipts. Reuse AUDIT-WP-0009's delivered reconciliation, cadence +registration and findings interfaces. No source sender, protected registry, +credential, live deployment, containment or profile acceptance was changed.