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
|
|
@ -202,7 +202,7 @@ human-readable view of it.
|
||||||
| `capability` | Needed for | `intended_owner` | `blocked_on` | `review` |
|
| `capability` | Needed for | `intended_owner` | `blocked_on` | `review` |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| Authentication and assurance evidence (token assurance, attestation outcomes, authentication anomalies) exposed as an engine surface | identity-drift posture | identity layer + `audit-core` — **`access-engine` declined** (v0.6 §13); reproposed, not assented | no engine surface exists; kings-guard consumes fixtures only | 2026-11-28 |
|
| Authentication and assurance evidence (token assurance, attestation outcomes, authentication anomalies) exposed as an engine surface | identity-drift posture | identity layer + `audit-core` — **`access-engine` declined** (v0.6 §13); reproposed, not assented | no engine surface exists; kings-guard consumes fixtures only | 2026-11-28 |
|
||||||
| Secret-use evidence (lease, revocation, mount and rotation metadata) exposed as an engine surface | secret-abuse posture | `secrets-engine` | no engine surface exists; kings-guard consumes fixtures only | 2026-11-28 |
|
| Secret-use evidence (lease, revocation, mount and rotation metadata) exposed as an engine surface | secret-abuse posture | `secrets-engine` | `secret-use snapshot` shipped with a 1d heartbeat declaration; snapshot parsing admitted under `KG-WP-0006`; event provenance and posture admission remain pending under `KG-IN-0005` | 2026-11-28 |
|
||||||
| Actuation surface — reduce authority, require step-up, isolate a workload — as a deterministic engine API carrying a decision record | containment kings-guard **proposes but does not own** | `access-engine` + runtime PEPs; not reviewed (`FLEX-DEC-2026-002`) | ruled an Engine concept held at zero (v0.6 §9.2); recorded here as a dependency, not a kings-guard gap to close | 2026-11-28 |
|
| Actuation surface — reduce authority, require step-up, isolate a workload — as a deterministic engine API carrying a decision record | containment kings-guard **proposes but does not own** | `access-engine` + runtime PEPs; not reviewed (`FLEX-DEC-2026-002`) | ruled an Engine concept held at zero (v0.6 §9.2); recorded here as a dependency, not a kings-guard gap to close | 2026-11-28 |
|
||||||
|
|
||||||
Until a gap closes, the corresponding posture lane stays advisory and
|
Until a gap closes, the corresponding posture lane stays advisory and
|
||||||
|
|
|
||||||
23
SCOPE.md
23
SCOPE.md
|
|
@ -6,10 +6,11 @@
|
||||||
|
|
||||||
## Layer
|
## Layer
|
||||||
|
|
||||||
**Staff** — interactive, non-deterministic; adaptive defence, observation,
|
**Staff** — interactive, non-deterministic; adaptive defence, observation, and
|
||||||
containment. Binding rule: kings-guard never touches Tooling directly; it acts
|
judgment. Kings-guard proposes containment but does not own or perform it.
|
||||||
only through Engine APIs. See `INTENT.md` and
|
Binding rule: kings-guard never touches Tooling directly; it acts only through
|
||||||
`net-kingdom/canon/standards/security-layer-model_v0.6.md`.
|
Engine APIs. See `INTENT.md` and
|
||||||
|
`net-kingdom/canon/standards/security-layer-model_v0.7.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -103,9 +104,17 @@ does it decide and act?** The first is in scope. The second is another layer's.
|
||||||
no-standing-credential claim executable; the companion cites them estate-wide
|
no-standing-credential claim executable; the companion cites them estate-wide
|
||||||
as the reference for a repository with no Tooling contacts at all.
|
as the reference for a repository with no Tooling contacts at all.
|
||||||
- Live observation of `qonto-assistant` uses the source's own AuditLogger emit
|
- Live observation of `qonto-assistant` uses the source's own AuditLogger emit
|
||||||
path. The hand-built fixture is retained as a regression case. Stream
|
path. The hand-built fixture is retained as a regression case. The local
|
||||||
completeness for the load-bearing deny class stays unknown until the source
|
contract proof now consumes the source-owned cadence declaration and checks
|
||||||
publishes a cadence declaration and heartbeats.
|
heartbeat, sequence, and reconciliation evidence for the load-bearing deny
|
||||||
|
class. Validation against a deployed stream remains operational work.
|
||||||
|
- Engine secret-use snapshots have a pure, scoped metadata adapter with explicit
|
||||||
|
unknown completeness and snapshot freshness findings. Event-based secret-abuse
|
||||||
|
posture awaits source provenance and operational evidence (`KG-IN-0005`). See
|
||||||
|
`docs/SecretUseSnapshotAdmission.md`.
|
||||||
|
- Cadence fixtures consume InfoTechCanon `standard/emission-cadence` 0.1;
|
||||||
|
the local handover draft is historical and NetKingdom profile adoption remains
|
||||||
|
with its owner.
|
||||||
- `specs/NetKingdomImmuneArchitecture.md` was swept under `KG-WP-0004` onto
|
- `specs/NetKingdomImmuneArchitecture.md` was swept under `KG-WP-0004` onto
|
||||||
Staff / Engine / Tooling (v0.7). It is an estate map; this repository
|
Staff / Engine / Tooling (v0.7). It is an estate map; this repository
|
||||||
implements the Staff slice only. Containment is proposed, not performed.
|
implements the Staff slice only. Containment is proposed, not performed.
|
||||||
|
|
|
||||||
|
|
@ -229,3 +229,32 @@ Until §17's schemas exist, two parties can disagree about whether they hold the
|
||||||
same evidence and recomputability is a thought experiment rather than a check.
|
same evidence and recomputability is a thought experiment rather than a check.
|
||||||
§17 is therefore load-bearing for this decision; `kings-guard` owns the
|
§17 is therefore load-bearing for this decision; `kings-guard` owns the
|
||||||
emission-cadence half under `KG-WP-0003-T02`.
|
emission-cadence half under `KG-WP-0003-T02`.
|
||||||
|
|
||||||
|
## KG-DEC-2026-003 — Admit scoped snapshots without claiming event completeness
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: KG-DEC-2026-003
|
||||||
|
kind: decision
|
||||||
|
title: Admit scoped snapshots without claiming event completeness
|
||||||
|
status: resolved
|
||||||
|
owner: codex
|
||||||
|
repo: kings-guard
|
||||||
|
origin_ref: KG-WP-0006
|
||||||
|
created: '2026-09-05'
|
||||||
|
updated: '2026-09-05'
|
||||||
|
decided_by: codex
|
||||||
|
state_hub_decision_id: "200e63b3-973a-4e3d-a687-3da0949bdcb6"
|
||||||
|
```
|
||||||
|
|
||||||
|
Consume secrets-engine snapshots through a pure typed adapter with caller-owned
|
||||||
|
catalog-to-tenant/subject bindings. Preserve omitted evidence as unknown and
|
||||||
|
assess only snapshot freshness. The source merges independently selected
|
||||||
|
historical fields without event timestamps or actors, so do not synthesize an
|
||||||
|
authorization event or infer current secret-abuse posture. Completeness stays
|
||||||
|
unknown; decision ids and session handles are not retained. Source provenance
|
||||||
|
and operational evidence are handed off as KG-IN-0005.
|
||||||
|
|
||||||
|
Consume InfoTechCanon standard/emission-cadence 0.1 in the Qonto fixtures;
|
||||||
|
NetKingdom security fields and local provenance are namespaced extensions.
|
||||||
|
The handover draft remains historical and NK-WP-0035 profile adoption stays
|
||||||
|
with its owner. Deployed Qonto validation remains KG-WP-0005-T03.
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ The following rules apply to every integration below:
|
||||||
4. `kings-guard` must preserve **tenant isolation**: any retained evidence or
|
4. `kings-guard` must preserve **tenant isolation**: any retained evidence or
|
||||||
memory must stay bounded by declared confidentiality rules.
|
memory must stay bounded by declared confidentiality rules.
|
||||||
5. `kings-guard` is a **Staff-layer** repository and is bound by §5 of the
|
5. `kings-guard` is a **Staff-layer** repository and is bound by §5 of the
|
||||||
NetKingdom Security Layer Model v0.6: it never holds a direct client for a
|
NetKingdom Security Layer Model v0.7: it never holds a direct client for a
|
||||||
Tooling-layer system. Evidence from Tooling (OpenBao, key-cape components)
|
Tooling-layer system. Evidence from Tooling (OpenBao, key-cape components)
|
||||||
is consumed **through the owning engine**. Where no engine surface exists,
|
is consumed **through the owning engine**. Where no engine surface exists,
|
||||||
the lane stays fixture-driven and the gap is declared in `INTENT.md`.
|
the lane stays fixture-driven and the gap is declared in `INTENT.md`.
|
||||||
|
|
|
||||||
40
docs/SecretUseSnapshotAdmission.md
Normal file
40
docs/SecretUseSnapshotAdmission.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Secret-use snapshot admission
|
||||||
|
|
||||||
|
`adapters.secrets_engine.snapshot_from_engine` consumes the non-secret envelope
|
||||||
|
returned by `secrets-engine secret-use snapshot --json`. It opens no client and
|
||||||
|
reads no Engine evidence directory. The caller obtains the Engine response and
|
||||||
|
supplies an explicit `catalog_id -> (tenant_id, subject_id)` binding; unbound or
|
||||||
|
duplicate lanes are rejected. Catalog identifiers alone do not establish scope.
|
||||||
|
|
||||||
|
| Source field | Consumer meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `surface`, `completeness_claimed` | Require `secret-use-evidence` and explicit `false`. |
|
||||||
|
| Envelope and lane `as_of` | Snapshot creation time; lane times must match. Not the times of underlying events. |
|
||||||
|
| `catalog_id`, `stage`, `kind` | Scoped catalog metadata. |
|
||||||
|
| `ready`, `revocation_attempted`, `revocation_succeeded` | Optional historical booleans. Absence stays unknown; false stays false. |
|
||||||
|
| `lifecycle_operation`, `evidence_kind` | Source-reported metadata, not a new event or inferred evidence classification. |
|
||||||
|
| `cadence.form`, `cadence.interval` | Require heartbeat form and a positive interval; translate `1d` to 86400 seconds. |
|
||||||
|
| Decision IDs, session handles, paths, mounts, field names, extra fields | Not retained in the normalized observation. |
|
||||||
|
|
||||||
|
The result is a separate `SecretUseSnapshot`, because `ImmuneObservation`
|
||||||
|
requires an event actor, event time, and outcome this surface cannot provide.
|
||||||
|
Readiness never becomes allow/deny. A snapshot older than the declared interval
|
||||||
|
receives `snapshot:stale`; a future snapshot receives `snapshot:future_timestamp`.
|
||||||
|
Even a fresh snapshot has **unknown completeness**. Its cadence declaration is
|
||||||
|
not a received heartbeat. It supplies neither heartbeat assertions nor
|
||||||
|
reconciliation counts, so generic stream evaluation is not invoked.
|
||||||
|
|
||||||
|
Each lane merges independently selected historical evidence. A newly created
|
||||||
|
snapshot may contain old readiness or revocation information. No content posture
|
||||||
|
or response proposal is generated from these untimed fields. KG-IN-0005 tracks
|
||||||
|
source event timestamps/provenance, evidence-class bindings, scoped heartbeat and
|
||||||
|
reconciliation evidence, and an authorized deployment capture before that lane
|
||||||
|
can support posture. Snapshot parsing is locally admitted; secret-abuse posture
|
||||||
|
remains pending.
|
||||||
|
|
||||||
|
Cadence examples for Qonto now follow InfoTechCanon
|
||||||
|
`standard/emission-cadence` 0.1. `extensions.net-kingdom` holds source evidence
|
||||||
|
classification and rate-monitoring metadata; `extensions.kings-guard` holds
|
||||||
|
provenance. The old handover spec is historical. Tests validate both fixtures
|
||||||
|
against the external owner schema when its checkout is available, without a
|
||||||
|
copied generic schema. NetKingdom profile adoption remains with NK-WP-0035.
|
||||||
|
|
@ -71,8 +71,8 @@ client request
|
||||||
| `protocol` | `protocol` | `rest` or `mcp` |
|
| `protocol` | `protocol` | `rest` or `mcp` |
|
||||||
| `decision` | `decision` | `allow` / `deny` |
|
| `decision` | `decision` | `allow` / `deny` |
|
||||||
| `deny_reason` | `deny_reason` | Pilot currently exercises `credential_exfil` |
|
| `deny_reason` | `deny_reason` | Pilot currently exercises `credential_exfil` |
|
||||||
| derived constant | `identity_binding` | `self_asserted` until key-cape integration lands |
|
| `identity_binding` | `identity_binding` | Direct mapping; legacy records fall back to the genome normalization hint |
|
||||||
| derived constant | `egress_destination` | `qonto-thirdparty-api` for this pilot |
|
| `egress_destination` | `egress_destination` | Direct mapping; legacy records fall back to the genome normalization hint |
|
||||||
| genome `evidence_sources` | `evidence_class` / `event_class` | Copied from the source declaration. `audit.deny` is load-bearing; `audit.allow` is attributive. Not inferred from `decision`. |
|
| genome `evidence_sources` | `evidence_class` / `event_class` | Copied from the source declaration. `audit.deny` is load-bearing; `audit.allow` is attributive. Not inferred from `decision`. |
|
||||||
|
|
||||||
## 6. Output Shape
|
## 6. Output Shape
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,35 @@
|
||||||
# Intake records
|
# Intake records
|
||||||
|
|
||||||
|
## KG-IN-0004 — Admit the shipped secrets-engine secret-use snapshot as observation input
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: KG-IN-0004
|
||||||
|
kind: intake
|
||||||
|
title: Admit the shipped secrets-engine secret-use snapshot as observation input
|
||||||
|
status: closed
|
||||||
|
outcome: promoted
|
||||||
|
promoted_to: KG-WP-0006
|
||||||
|
closed: '2026-09-05'
|
||||||
|
origin: cross-repo
|
||||||
|
origin_ref: SECRETS-WP-0008-T05
|
||||||
|
priority: medium
|
||||||
|
owner: kings-guard
|
||||||
|
requested_by: secrets-engine
|
||||||
|
description: >
|
||||||
|
secrets-engine has shipped `secrets-engine secret-use snapshot [--catalog-id
|
||||||
|
ID] [--json]` as a read-only Engine / Lifecycle surface over non-secret local
|
||||||
|
evidence and catalog metadata. The envelope explicitly denies completeness,
|
||||||
|
omits evidence-derived fields when no record exists, and declares a 1d
|
||||||
|
heartbeat through `secrets-engine evidence heartbeat`. Review and admit the
|
||||||
|
surface as an immune-observation input without adding a Tooling client,
|
||||||
|
treating omission as non-occurrence, or treating readiness or decision ids
|
||||||
|
as cached authorization. Define the lane-row mapping, cadence translation,
|
||||||
|
stale-snapshot behavior, and tests before changing the secret-observation
|
||||||
|
capability from pending.
|
||||||
|
created: '2026-09-04'
|
||||||
|
updated: '2026-09-05'
|
||||||
|
```
|
||||||
|
|
||||||
## KG-IN-0001 — Assent requested: Staff layer placement, control-plane vocabulary, and the posture asymmetry
|
## KG-IN-0001 — Assent requested: Staff layer placement, control-plane vocabulary, and the posture asymmetry
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|
@ -123,3 +153,30 @@ resolution: 'Answered with a proposed revision. The boundary is recomputability,
|
||||||
docs/PostureMaturityBoundary.md. Decision: KG-DEC-2026-002.'
|
docs/PostureMaturityBoundary.md. Decision: KG-DEC-2026-002.'
|
||||||
state_hub_intake_id: "01a04d8d-66f8-70ef-b2b5-1eff03569471"
|
state_hub_intake_id: "01a04d8d-66f8-70ef-b2b5-1eff03569471"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## KG-IN-0005 — Source evidence needed for secret-abuse posture
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: KG-IN-0005
|
||||||
|
kind: intake
|
||||||
|
title: Source evidence needed for secret-abuse posture
|
||||||
|
status: open
|
||||||
|
origin: residual
|
||||||
|
origin_ref: KG-WP-0006
|
||||||
|
priority: medium
|
||||||
|
owner: kings-guard
|
||||||
|
requested_by: kings-guard
|
||||||
|
related:
|
||||||
|
- SECRETS-WP-0008-T05
|
||||||
|
- KG-IN-0004
|
||||||
|
description: >
|
||||||
|
Snapshot parsing is admitted, but the secrets-engine envelope combines
|
||||||
|
historical fields without their event timestamps, actors or record provenance.
|
||||||
|
Obtain an Engine-owned event/provenance contract and explicit evidence-class
|
||||||
|
bindings, scoped heartbeat assertions and reconciliation evidence, plus an
|
||||||
|
authorized deployment capture. Review those inputs before enabling secret-abuse
|
||||||
|
posture. Keep snapshot completeness unknown and never infer allow/deny from
|
||||||
|
readiness, lifecycle metadata or decision ids. No direct Tooling contact.
|
||||||
|
created: '2026-09-05'
|
||||||
|
updated: '2026-09-05'
|
||||||
|
```
|
||||||
|
|
|
||||||
10
layer.yaml
10
layer.yaml
|
|
@ -4,7 +4,7 @@
|
||||||
# Assent: decisions/decisions.md KG-DEC-2026-001 (kings-guard's own voice, §11)
|
# Assent: decisions/decisions.md KG-DEC-2026-001 (kings-guard's own voice, §11)
|
||||||
# Validate: python3 scripts/check_layer_conformance.py
|
# Validate: python3 scripts/check_layer_conformance.py
|
||||||
#
|
#
|
||||||
# §11 (v0.6) requires a machine-readable declaration: prose cannot distinguish a
|
# §11 (v0.7) requires a machine-readable declaration: prose cannot distinguish a
|
||||||
# declaration from a transcribed review. Form adapted from ops-warden's
|
# declaration from a transcribed review. Form adapted from ops-warden's
|
||||||
# reference implementation, offered under §11.
|
# reference implementation, offered under §11.
|
||||||
#
|
#
|
||||||
|
|
@ -109,10 +109,12 @@ unowned_capabilities:
|
||||||
Lease, revocation, mount and rotation metadata exposed as an engine
|
Lease, revocation, mount and rotation metadata exposed as an engine
|
||||||
surface, for secret-abuse posture.
|
surface, for secret-abuse posture.
|
||||||
intended_owner: secrets-engine
|
intended_owner: secrets-engine
|
||||||
owner_status: proposed
|
owner_status: "snapshot parsing admitted; posture admission pending"
|
||||||
blocked_on: >-
|
blocked_on: >-
|
||||||
No engine exposes secret-use evidence. OpenBao is Tooling; same reasoning
|
secrets-engine now exposes `secret-use snapshot`, a non-secret local
|
||||||
as above.
|
evidence surface with a 1d heartbeat declaration. Envelope and scoped lane
|
||||||
|
parsing are admitted under KG-WP-0006; event provenance, heartbeat evidence
|
||||||
|
and posture admission remain pending under KG-IN-0005.
|
||||||
review: "2026-11-28"
|
review: "2026-11-28"
|
||||||
consequence: "secret-abuse posture lane stays fixture-driven"
|
consequence: "secret-abuse posture lane stays fixture-driven"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ dev = [
|
||||||
# DEV dependency: `dependencies = []` above is load-bearing for the §5
|
# DEV dependency: `dependencies = []` above is load-bearing for the §5
|
||||||
# no-Tooling-client claim and must stay empty.
|
# no-Tooling-client claim and must stay empty.
|
||||||
"pyyaml>=6.0,<7.0",
|
"pyyaml>=6.0,<7.0",
|
||||||
|
"jsonschema>=4.23,<5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Read-only. kings-guard's whole position under the standard rests on one claim:
|
||||||
it holds no direct client for any Tooling-layer system, and the
|
it holds no direct client for any Tooling-layer system, and the
|
||||||
capabilities that would need one sit at zero instead (§11 blocked-clean)
|
capabilities that would need one sit at zero instead (§11 blocked-clean)
|
||||||
|
|
||||||
That claim has been asserted in prose since KG-DEC-2026-001. §11 (v0.6) requires
|
That claim has been asserted in prose since KG-DEC-2026-001. §11 (v0.7) requires
|
||||||
a machine-readable declaration because prose cannot distinguish a declaration
|
a machine-readable declaration because prose cannot distinguish a declaration
|
||||||
from a transcribed review. This script is what makes the claim checkable: it
|
from a transcribed review. This script is what makes the claim checkable: it
|
||||||
fails if a Tooling client appears in src/ without a matching layer.yaml entry.
|
fails if a Tooling client appears in src/ without a matching layer.yaml entry.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ classification: Public
|
||||||
|
|
||||||
# Emission-cadence declaration — Taxonomy draft
|
# Emission-cadence declaration — Taxonomy draft
|
||||||
|
|
||||||
|
> Historical handover, frozen after assimilation. InfoTechCanon published
|
||||||
|
> `standard/emission-cadence` schema 0.1 in canon 0.7.0 (ITC-WP-0018).
|
||||||
|
> Canonical schema: `info-tech-canon/infospace/schemas/emission-cadence.schema.yaml`.
|
||||||
|
> Runtime fixtures now consume that shape; NetKingdom owns the security profile
|
||||||
|
> (NK-WP-0035, still proposed). The original proposal below is provenance.
|
||||||
|
|
||||||
kings-guard drafts this artifact because it is the only consumer of
|
kings-guard drafts this artifact because it is the only consumer of
|
||||||
silence-as-signal and cannot implement §9.6 without a declared cadence.
|
silence-as-signal and cannot implement §9.6 without a declared cadence.
|
||||||
**Ownership stays with Taxonomy.** This file is a handover, not an internal
|
**Ownership stays with Taxonomy.** This file is a handover, not an internal
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,16 @@ def observation_from_audit_event(
|
||||||
evidence_class=resolved_class,
|
evidence_class=resolved_class,
|
||||||
event_class=resolved_event_class,
|
event_class=resolved_event_class,
|
||||||
deny_reason=_optional_str(event.get("deny_reason")),
|
deny_reason=_optional_str(event.get("deny_reason")),
|
||||||
identity_binding=identity_binding,
|
identity_binding=(
|
||||||
egress_destination=egress_destination,
|
_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")),
|
latency_ms=_optional_int(event.get("latency_ms")),
|
||||||
result_count=_optional_int(event.get("result_count")),
|
result_count=_optional_int(event.get("result_count")),
|
||||||
policy_version=_optional_int(event.get("policy_version")),
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
@ -32,15 +33,10 @@ def parse_interval(value: str | int) -> timedelta:
|
||||||
return timedelta(hours=int(text[:-1]))
|
return timedelta(hours=int(text[:-1]))
|
||||||
if text.endswith("d") and text[:-1].isdigit():
|
if text.endswith("d") and text[:-1].isdigit():
|
||||||
return timedelta(days=int(text[:-1]))
|
return timedelta(days=int(text[:-1]))
|
||||||
if text.startswith("pt"):
|
match = re.fullmatch(r"p(?:(\d+)d)?(?:t(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?)?", text)
|
||||||
# Minimal ISO-8601 duration: PT24H, PT1H, PT30M.
|
if match and any(match.groups()):
|
||||||
amount = text[2:]
|
days, hours, minutes, seconds = (int(value or 0) for value in match.groups())
|
||||||
if amount.endswith("h") and amount[:-1].isdigit():
|
return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
|
||||||
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}")
|
raise ValueError(f"unsupported interval: {value!r}")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -71,10 +67,10 @@ class ReconciliationCadence:
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class EmissionCadence:
|
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`,
|
NetKingdom classifications and local provenance live in extensions.
|
||||||
not a competing schema. Ownership stays with Taxonomy.
|
Canonical schema validation belongs to the owner's schema, not a local copy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
schema_version: str
|
schema_version: str
|
||||||
|
|
@ -106,12 +102,27 @@ def load_qonto_assistant_cadence() -> EmissionCadence:
|
||||||
return emission_cadence_from_dict(payload)
|
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:
|
def emission_cadence_from_dict(data: Mapping[str, Any]) -> EmissionCadence:
|
||||||
rates: list[RateCadence] = []
|
rates: list[RateCadence] = []
|
||||||
heartbeats: list[HeartbeatCadence] = []
|
heartbeats: list[HeartbeatCadence] = []
|
||||||
reconciliations: list[ReconciliationCadence] = []
|
reconciliations: list[ReconciliationCadence] = []
|
||||||
for item in data.get("sources", ()):
|
if data.get("schema_version") != "0.1" or not data.get("declaration_id"):
|
||||||
evidence_class = EvidenceClass(str(item["evidence_class"]))
|
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"])
|
form = str(item["form"])
|
||||||
if form == "expected-rate":
|
if form == "expected-rate":
|
||||||
rates.append(
|
rates.append(
|
||||||
|
|
@ -150,11 +161,11 @@ def emission_cadence_from_dict(data: Mapping[str, Any]) -> EmissionCadence:
|
||||||
)
|
)
|
||||||
return EmissionCadence(
|
return EmissionCadence(
|
||||||
schema_version=str(data.get("schema_version", "0.1")),
|
schema_version=str(data.get("schema_version", "0.1")),
|
||||||
status=str(data.get("status", "taxonomy-draft")),
|
status=str(provenance.get("status", "source-declared")),
|
||||||
drafter=str(data.get("drafter", "kings-guard")),
|
drafter=str(provenance.get("drafter", data["source"])),
|
||||||
owner=str(data.get("owner", "Taxonomy")),
|
owner=str(provenance.get("owner", data["source"])),
|
||||||
source_system=str(data.get("source", data.get("source_system", "unknown"))),
|
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),
|
rates=tuple(rates),
|
||||||
heartbeats=tuple(heartbeats),
|
heartbeats=tuple(heartbeats),
|
||||||
reconciliations=tuple(reconciliations),
|
reconciliations=tuple(reconciliations),
|
||||||
|
|
|
||||||
|
|
@ -140,12 +140,9 @@ class SecurityGenome:
|
||||||
permitted_protocols=frozenset(str(item) for item in data["permitted_protocols"]),
|
permitted_protocols=frozenset(str(item) for item in data["permitted_protocols"]),
|
||||||
permitted_egress=frozenset(str(item) for item in data["permitted_egress"]),
|
permitted_egress=frozenset(str(item) for item in data["permitted_egress"]),
|
||||||
data_classifications=tuple(str(item) for item in data.get("data_classifications", ())),
|
data_classifications=tuple(str(item) for item in data.get("data_classifications", ())),
|
||||||
tolerances=tuple(
|
tolerances=tuple(ToleranceRule.from_dict(item) for item in data.get("tolerances", ())),
|
||||||
ToleranceRule.from_dict(item) for item in data.get("tolerances", ())
|
|
||||||
),
|
|
||||||
evidence_sources=tuple(
|
evidence_sources=tuple(
|
||||||
DeclaredEvidenceSource.from_dict(item)
|
DeclaredEvidenceSource.from_dict(item) for item in data.get("evidence_sources", ())
|
||||||
for item in data.get("evidence_sources", ())
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,31 @@
|
||||||
{
|
{
|
||||||
"schema_version": "0.1",
|
"schema_version": "0.1",
|
||||||
"status": "taxonomy-draft",
|
|
||||||
"drafter": "kings-guard",
|
|
||||||
"owner": "Taxonomy",
|
|
||||||
"source": "qonto-assistant",
|
"source": "qonto-assistant",
|
||||||
"belongs_alongside": "security_genome",
|
|
||||||
"reference_instance": "GH-WP-0002-T04",
|
|
||||||
"reference_source_declaration": "approval-engine/cadence.yaml",
|
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"source_id": "qonto-assistant.audit.allow",
|
"source_id": "qonto-assistant.audit.allow",
|
||||||
"source_system": "qonto-assistant",
|
"source_system": "qonto-assistant",
|
||||||
"event_class": "audit.allow",
|
"event_class": "audit.allow",
|
||||||
"evidence_class": "attributive",
|
|
||||||
"form": "expected-rate",
|
"form": "expected-rate",
|
||||||
"window": "24h",
|
|
||||||
"window_seconds": 86400,
|
"window_seconds": 86400,
|
||||||
"expected_min": 1,
|
"expected_min": 1,
|
||||||
"drop_below": "finding",
|
"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_id": "qonto-assistant.audit.deny",
|
||||||
"source_system": "qonto-assistant",
|
"source_system": "qonto-assistant",
|
||||||
"event_class": "audit.deny",
|
"event_class": "audit.deny",
|
||||||
"evidence_class": "load-bearing",
|
|
||||||
"form": "heartbeat-or-reconciliation",
|
"form": "heartbeat-or-reconciliation",
|
||||||
"rate_monitoring": "forbidden",
|
|
||||||
"heartbeat": {
|
"heartbeat": {
|
||||||
"event_class": "audit.heartbeat",
|
"event_class": "audit.heartbeat",
|
||||||
"interval": "24h",
|
|
||||||
"interval_seconds": 86400,
|
"interval_seconds": 86400,
|
||||||
"assertion": "nothing-to-report",
|
"assertion": "nothing-to-report",
|
||||||
"missing": "finding"
|
"missing": "finding"
|
||||||
|
|
@ -38,7 +34,24 @@
|
||||||
"compare_local": "source_transition_counts.audit.deny",
|
"compare_local": "source_transition_counts.audit.deny",
|
||||||
"compare_observed": "evidence_counts.audit.deny",
|
"compare_observed": "evidence_counts.audit.deny",
|
||||||
"divergence": "finding"
|
"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 typing import Any
|
||||||
|
|
||||||
from kings_guard.adapters import observation_from_audit_event
|
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_ASSISTANT_SRC = Path("/home/worsch/qonto-assistant/src")
|
||||||
QONTO_POLICY = (
|
QONTO_POLICY = Path("/home/worsch/qonto-assistant/src/qonto_assistant/policy/qonto-v1.yaml")
|
||||||
Path("/home/worsch/qonto-assistant/src/qonto_assistant/policy/qonto-v1.yaml")
|
|
||||||
)
|
|
||||||
QONTO_FIXTURES = Path("/home/worsch/qonto-assistant/tests/fixtures/qonto")
|
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:
|
class LiveQontoCapture:
|
||||||
events: tuple[dict[str, Any], ...]
|
events: tuple[dict[str, Any], ...]
|
||||||
observations: tuple[ImmuneObservation, ...]
|
observations: tuple[ImmuneObservation, ...]
|
||||||
|
heartbeats: tuple[StreamHeartbeat, ...]
|
||||||
|
reconciliation: ReconciliationView
|
||||||
mapping_notes: tuple[str, ...]
|
mapping_notes: tuple[str, ...]
|
||||||
corrections_for_source: tuple[str, ...]
|
corrections_for_source: tuple[str, ...]
|
||||||
|
stream_notes: tuple[str, ...]
|
||||||
|
stream_corrections: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
def qonto_assistant_available() -> bool:
|
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.
|
client is opened; the source publishes its own stream.
|
||||||
"""
|
"""
|
||||||
if not qonto_assistant_available():
|
if not qonto_assistant_available():
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(f"qonto-assistant checkout not found at {QONTO_ASSISTANT_SRC}")
|
||||||
f"qonto-assistant checkout not found at {QONTO_ASSISTANT_SRC}"
|
|
||||||
)
|
|
||||||
|
|
||||||
src = str(QONTO_ASSISTANT_SRC)
|
src = str(QONTO_ASSISTANT_SRC)
|
||||||
if src not in sys.path:
|
if src not in sys.path:
|
||||||
|
|
@ -53,6 +58,8 @@ def capture_qonto_assistant_events(genome: SecurityGenome) -> LiveQontoCapture:
|
||||||
from qonto_assistant.service import CapabilityService
|
from qonto_assistant.service import CapabilityService
|
||||||
|
|
||||||
events: list[dict[str, Any]] = []
|
events: list[dict[str, Any]] = []
|
||||||
|
audit_logger = AuditLogger(sink=events.append)
|
||||||
|
audit_logger.emit_heartbeat(reason="startup")
|
||||||
service = CapabilityService(
|
service = CapabilityService(
|
||||||
client=FixtureQontoClient(fixture_dir=QONTO_FIXTURES),
|
client=FixtureQontoClient(fixture_dir=QONTO_FIXTURES),
|
||||||
policy=PolicyEngine.from_file(
|
policy=PolicyEngine.from_file(
|
||||||
|
|
@ -60,7 +67,7 @@ def capture_qonto_assistant_events(genome: SecurityGenome) -> LiveQontoCapture:
|
||||||
required_scope="finance.qonto.read",
|
required_scope="finance.qonto.read",
|
||||||
enforce_scope=False,
|
enforce_scope=False,
|
||||||
),
|
),
|
||||||
audit_logger=AuditLogger(sink=events.append),
|
audit_logger=audit_logger,
|
||||||
rate_limiter=RateLimiter(limit=100, window_seconds=60),
|
rate_limiter=RateLimiter(limit=100, window_seconds=60),
|
||||||
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
||||||
)
|
)
|
||||||
|
|
@ -89,8 +96,13 @@ def capture_qonto_assistant_events(genome: SecurityGenome) -> LiveQontoCapture:
|
||||||
else:
|
else:
|
||||||
raise RuntimeError("expected qonto-assistant to emit a deny for oversized page_size")
|
raise RuntimeError("expected qonto-assistant to emit a deny for oversized page_size")
|
||||||
|
|
||||||
if len(events) < 2:
|
audit_logger.emit_heartbeat(reason="periodic")
|
||||||
raise RuntimeError(f"expected allow and deny audit events, got {len(events)}")
|
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(
|
observations = tuple(
|
||||||
observation_from_audit_event(
|
observation_from_audit_event(
|
||||||
|
|
@ -101,14 +113,25 @@ def capture_qonto_assistant_events(genome: SecurityGenome) -> LiveQontoCapture:
|
||||||
egress_destination="qonto-thirdparty-api",
|
egress_destination="qonto-thirdparty-api",
|
||||||
genome=genome,
|
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(
|
return LiveQontoCapture(
|
||||||
events=tuple(events),
|
events=request_events,
|
||||||
observations=observations,
|
observations=observations,
|
||||||
|
heartbeats=heartbeats,
|
||||||
|
reconciliation=reconciliation,
|
||||||
mapping_notes=mapping_notes,
|
mapping_notes=mapping_notes,
|
||||||
corrections_for_source=corrections,
|
corrections_for_source=corrections,
|
||||||
|
stream_notes=stream_notes,
|
||||||
|
stream_corrections=stream_corrections,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -166,6 +189,14 @@ def _confirm_mapping(
|
||||||
corrections.append("actor mapping drifted")
|
corrections.append("actor mapping drifted")
|
||||||
if observation.decision.value != str(event["decision"]):
|
if observation.decision.value != str(event["decision"]):
|
||||||
corrections.append("decision mapping drifted")
|
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(
|
notes.append(
|
||||||
f"{event['request_id']}: decision={event['decision']} "
|
f"{event['request_id']}: decision={event['decision']} "
|
||||||
f"capability={event['capability']} protocol={event['protocol']} "
|
f"capability={event['capability']} protocol={event['protocol']} "
|
||||||
|
|
@ -196,3 +227,95 @@ def _read_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||||
continue
|
continue
|
||||||
events.append(json.loads(text))
|
events.append(json.loads(text))
|
||||||
return events
|
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")
|
findings.append("unexpected_egress_destination")
|
||||||
|
|
||||||
if observation.identity_binding and observation.identity_binding != "verified_token":
|
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:
|
if tolerance is None:
|
||||||
findings.append("unverified_identity_binding")
|
findings.append("unverified_identity_binding")
|
||||||
else:
|
else:
|
||||||
|
|
@ -357,9 +359,8 @@ def _build_rationale(
|
||||||
) -> str:
|
) -> str:
|
||||||
if posture is PostureLevel.HEALTHY:
|
if posture is PostureLevel.HEALTHY:
|
||||||
if tolerated_findings:
|
if tolerated_findings:
|
||||||
content = (
|
content = "Healthy posture with tolerated deviations only: " + ", ".join(
|
||||||
"Healthy posture with tolerated deviations only: "
|
tolerated_findings
|
||||||
+ ", ".join(tolerated_findings)
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
content = "Healthy posture: observation is compatible with declared intent."
|
content = "Healthy posture: observation is compatible with declared intent."
|
||||||
|
|
@ -375,10 +376,7 @@ def _build_rationale(
|
||||||
if completeness is StreamCompleteness.COMPLETE:
|
if completeness is StreamCompleteness.COMPLETE:
|
||||||
stream_text = "Stream completeness is complete."
|
stream_text = "Stream completeness is complete."
|
||||||
elif completeness is StreamCompleteness.DEGRADED:
|
elif completeness is StreamCompleteness.DEGRADED:
|
||||||
stream_text = (
|
stream_text = f"This judgment rests on a stream I cannot vouch for ({completeness_reason})."
|
||||||
"This judgment rests on a stream I cannot vouch for "
|
|
||||||
f"({completeness_reason})."
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
stream_text = (
|
stream_text = (
|
||||||
"Stream completeness is unknown; confidence scores the record, "
|
"Stream completeness is unknown; confidence scores the record, "
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,14 @@ def evaluate_stream(
|
||||||
now_dt = _as_datetime(now)
|
now_dt = _as_datetime(now)
|
||||||
findings: list[str] = []
|
findings: list[str] = []
|
||||||
reasons: 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)
|
observed_counts = _count_observations(observations)
|
||||||
|
|
||||||
watch_start = _as_datetime(watching_since) if watching_since is not None else None
|
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
|
observed_counts[rate.event_class] = count
|
||||||
watched_long_enough = (now_dt - watch_start) >= rate.window
|
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:
|
if watched_long_enough and count < rate.expected_min:
|
||||||
findings.append(f"{STREAM_FINDING_PREFIX}cadence_unmet:{rate.event_class}")
|
findings.append(f"{STREAM_FINDING_PREFIX}cadence_unmet:{rate.event_class}")
|
||||||
reasons.append(
|
reasons.append(
|
||||||
|
|
@ -78,16 +88,19 @@ def evaluate_stream(
|
||||||
|
|
||||||
if cadence.reconciliations:
|
if cadence.reconciliations:
|
||||||
if reconciliation is None:
|
if reconciliation is None:
|
||||||
reasons.append(
|
reasons.append("reconciliation view was not supplied; divergence cannot be ruled out")
|
||||||
"reconciliation view was not supplied; divergence cannot be ruled out"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
for spec in cadence.reconciliations:
|
for spec in cadence.reconciliations:
|
||||||
source_count = int(reconciliation.source_counts.get(spec.covered_event_class, 0))
|
source_count = reconciliation.source_counts.get(spec.covered_event_class)
|
||||||
evidence_count = int(
|
evidence_count = reconciliation.evidence_counts.get(spec.covered_event_class)
|
||||||
reconciliation.evidence_counts.get(spec.covered_event_class, 0)
|
if any(type(value) is not int or value < 0
|
||||||
)
|
for value in (source_count, evidence_count)):
|
||||||
if evidence_count < source_count:
|
reasons.append(
|
||||||
|
"valid reconciliation counts were not supplied for "
|
||||||
|
f"{spec.covered_event_class}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if evidence_count != source_count:
|
||||||
findings.append(
|
findings.append(
|
||||||
f"{STREAM_FINDING_PREFIX}reconciliation_divergence:{spec.covered_event_class}"
|
f"{STREAM_FINDING_PREFIX}reconciliation_divergence:{spec.covered_event_class}"
|
||||||
)
|
)
|
||||||
|
|
@ -98,9 +111,7 @@ def evaluate_stream(
|
||||||
|
|
||||||
completeness = _completeness(findings, reasons, cadence)
|
completeness = _completeness(findings, reasons, cadence)
|
||||||
reason = (
|
reason = (
|
||||||
"; ".join(reasons)
|
"; ".join(reasons) if reasons else "declared cadence is met and no stream finding is open"
|
||||||
if reasons
|
|
||||||
else "declared cadence is met and no stream finding is open"
|
|
||||||
)
|
)
|
||||||
return StreamAssessment(
|
return StreamAssessment(
|
||||||
completeness=completeness,
|
completeness=completeness,
|
||||||
|
|
@ -118,9 +129,7 @@ def _completeness(
|
||||||
) -> StreamCompleteness:
|
) -> StreamCompleteness:
|
||||||
if findings:
|
if findings:
|
||||||
return StreamCompleteness.DEGRADED
|
return StreamCompleteness.DEGRADED
|
||||||
heartbeat_pending = any("not yet due" in item for item in reasons)
|
if reasons:
|
||||||
reconciliation_unsupplied = any("was not supplied" in item for item in reasons)
|
|
||||||
if heartbeat_pending or reconciliation_unsupplied:
|
|
||||||
return StreamCompleteness.UNKNOWN
|
return StreamCompleteness.UNKNOWN
|
||||||
if not cadence.heartbeats and not cadence.rates and not cadence.reconciliations:
|
if not cadence.heartbeats and not cadence.rates and not cadence.reconciliations:
|
||||||
return StreamCompleteness.UNKNOWN
|
return StreamCompleteness.UNKNOWN
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from kings_guard.cadence import load_qonto_assistant_source_cadence
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DRAFT = ROOT / "specs" / "EmissionCadenceDeclaration.md"
|
DRAFT = ROOT / "specs" / "EmissionCadenceDeclaration.md"
|
||||||
|
QONTO_SOURCE_DECLARATION = Path("/home/worsch/qonto-assistant/specs/audit-emission-cadence.yaml")
|
||||||
|
|
||||||
|
|
||||||
def test_emission_cadence_draft_exists_for_taxonomy_handover() -> None:
|
def test_emission_cadence_draft_exists_for_taxonomy_handover() -> None:
|
||||||
|
|
@ -17,3 +22,62 @@ def test_emission_cadence_draft_exists_for_taxonomy_handover() -> None:
|
||||||
assert "reconciliation" in text.lower()
|
assert "reconciliation" in text.lower()
|
||||||
assert "alongside the security genome" in text.lower() or "alongside the genome" in text.lower()
|
assert "alongside the security genome" in text.lower() or "alongside the genome" in text.lower()
|
||||||
assert "security_genome" in text
|
assert "security_genome" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_qonto_source_declaration_is_consumed_separately_from_the_taxonomy_draft() -> None:
|
||||||
|
cadence = load_qonto_assistant_source_cadence()
|
||||||
|
|
||||||
|
assert cadence.status == "source-declared"
|
||||||
|
assert cadence.owner == "qonto-assistant"
|
||||||
|
assert cadence.reference_instance == "QONTO-WP-0005"
|
||||||
|
assert cadence.forms() == {"heartbeat-or-reconciliation"}
|
||||||
|
assert cadence.rates == ()
|
||||||
|
assert cadence.heartbeats[0].interval.total_seconds() == 86400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not QONTO_SOURCE_DECLARATION.is_file(),
|
||||||
|
reason="qonto-assistant source declaration checkout is unavailable",
|
||||||
|
)
|
||||||
|
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"
|
||||||
|
]
|
||||||
|
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
|
||||||
|
assert cadence.rates == ()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("filename", [
|
||||||
|
"qonto_assistant_cadence.json", "qonto_assistant_source_cadence.json",
|
||||||
|
])
|
||||||
|
def test_cadence_fixtures_conform_to_owner_schema(filename):
|
||||||
|
import json
|
||||||
|
|
||||||
|
import jsonschema
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
schema_path = Path(
|
||||||
|
"/home/worsch/info-tech-canon/infospace/schemas/emission-cadence.schema.yaml"
|
||||||
|
)
|
||||||
|
if not schema_path.is_file():
|
||||||
|
pytest.skip("canonical InfoTechCanon schema checkout is unavailable")
|
||||||
|
schema = yaml.safe_load(schema_path.read_text())
|
||||||
|
payload = json.loads((ROOT / "src/kings_guard/fixtures" / filename).read_text())
|
||||||
|
jsonschema.Draft202012Validator(schema).validate(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("value", "seconds"), [("P1DT2H30M5S", 95405), ("P1D", 86400)])
|
||||||
|
def test_canonical_compound_durations_are_consumed(value, seconds):
|
||||||
|
from kings_guard.cadence import parse_interval
|
||||||
|
|
||||||
|
assert parse_interval(value).total_seconds() == seconds
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from helpers import load_pilot, observation_from_fixture
|
||||||
|
|
||||||
from kings_guard.cadence import load_qonto_assistant_cadence
|
from kings_guard.cadence import load_qonto_assistant_cadence
|
||||||
from kings_guard.contracts import (
|
from kings_guard.contracts import (
|
||||||
PostureAssessment,
|
PostureAssessment,
|
||||||
|
|
@ -9,8 +11,6 @@ from kings_guard.contracts import (
|
||||||
)
|
)
|
||||||
from kings_guard.posture import PostureEvaluator
|
from kings_guard.posture import PostureEvaluator
|
||||||
|
|
||||||
from helpers import load_pilot, observation_from_fixture
|
|
||||||
|
|
||||||
|
|
||||||
def _healthy_assessment(*, completeness: StreamCompleteness, confidence: int) -> PostureAssessment:
|
def _healthy_assessment(*, completeness: StreamCompleteness, confidence: int) -> PostureAssessment:
|
||||||
return PostureAssessment(
|
return PostureAssessment(
|
||||||
|
|
@ -33,7 +33,10 @@ def test_completeness_is_separated_from_record_richness() -> None:
|
||||||
assert evaluation.assessment.confidence_score >= 85
|
assert evaluation.assessment.confidence_score >= 85
|
||||||
assert evaluation.assessment.stream_completeness is StreamCompleteness.UNKNOWN
|
assert evaluation.assessment.stream_completeness is StreamCompleteness.UNKNOWN
|
||||||
assert "record" in evaluation.assessment.completeness_reason
|
assert "record" in evaluation.assessment.completeness_reason
|
||||||
assert "cannot vouch" in evaluation.assessment.rationale or "unknown" in evaluation.assessment.rationale
|
assert (
|
||||||
|
"cannot vouch" in evaluation.assessment.rationale
|
||||||
|
or "unknown" in evaluation.assessment.rationale
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_unmet_cadence_degrades_completeness_and_says_so_in_words() -> None:
|
def test_unmet_cadence_degrades_completeness_and_says_so_in_words() -> None:
|
||||||
|
|
@ -47,7 +50,9 @@ def test_unmet_cadence_degrades_completeness_and_says_so_in_words() -> None:
|
||||||
now="2026-07-24T10:10:00Z",
|
now="2026-07-24T10:10:00Z",
|
||||||
watching_since="2026-07-22T09:10:00Z",
|
watching_since="2026-07-22T09:10:00Z",
|
||||||
heartbeats=(),
|
heartbeats=(),
|
||||||
reconciliation=ReconciliationView(source_counts={"audit.deny": 1}, evidence_counts={"audit.deny": 1}),
|
reconciliation=ReconciliationView(
|
||||||
|
source_counts={"audit.deny": 1}, evidence_counts={"audit.deny": 1}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert evaluation.assessment.stream_completeness is StreamCompleteness.DEGRADED
|
assert evaluation.assessment.stream_completeness is StreamCompleteness.DEGRADED
|
||||||
|
|
@ -56,12 +61,8 @@ def test_unmet_cadence_degrades_completeness_and_says_so_in_words() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_incomplete_stream_is_never_more_trustworthy_than_a_complete_one() -> None:
|
def test_incomplete_stream_is_never_more_trustworthy_than_a_complete_one() -> None:
|
||||||
rich_incomplete = _healthy_assessment(
|
rich_incomplete = _healthy_assessment(completeness=StreamCompleteness.DEGRADED, confidence=95)
|
||||||
completeness=StreamCompleteness.DEGRADED, confidence=95
|
sparse_complete = _healthy_assessment(completeness=StreamCompleteness.COMPLETE, confidence=70)
|
||||||
)
|
|
||||||
sparse_complete = _healthy_assessment(
|
|
||||||
completeness=StreamCompleteness.COMPLETE, confidence=70
|
|
||||||
)
|
|
||||||
unknown = _healthy_assessment(completeness=StreamCompleteness.UNKNOWN, confidence=95)
|
unknown = _healthy_assessment(completeness=StreamCompleteness.UNKNOWN, confidence=95)
|
||||||
|
|
||||||
assert assessment_trust_key(rich_incomplete) < assessment_trust_key(sparse_complete)
|
assert assessment_trust_key(rich_incomplete) < assessment_trust_key(sparse_complete)
|
||||||
|
|
@ -103,7 +104,9 @@ def test_complete_stream_keeps_richness_and_states_completeness() -> None:
|
||||||
counts={"audit.deny": 1},
|
counts={"audit.deny": 1},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
reconciliation=ReconciliationView(source_counts={"audit.deny": 1}, evidence_counts={"audit.deny": 1}),
|
reconciliation=ReconciliationView(
|
||||||
|
source_counts={"audit.deny": 1}, evidence_counts={"audit.deny": 1}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert evaluation.assessment.stream_completeness is StreamCompleteness.COMPLETE
|
assert evaluation.assessment.stream_completeness is StreamCompleteness.COMPLETE
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
|
from helpers import load_pilot, observation_from_fixture
|
||||||
|
|
||||||
from kings_guard.contracts import PostureLevel, SecurityGenome, as_jsonable
|
from kings_guard.contracts import PostureLevel, SecurityGenome, as_jsonable
|
||||||
from kings_guard.posture import PostureEvaluator
|
from kings_guard.posture import PostureEvaluator
|
||||||
|
|
||||||
from helpers import load_pilot, observation_from_fixture
|
|
||||||
|
|
||||||
|
|
||||||
def test_qonto_fixture_loads_a_normalized_genome() -> None:
|
def test_qonto_fixture_loads_a_normalized_genome() -> None:
|
||||||
fixture = load_pilot()
|
fixture = load_pilot()
|
||||||
|
|
@ -25,6 +25,18 @@ def test_qonto_audit_event_normalizes_to_immune_observation() -> None:
|
||||||
assert observation.deny_reason == "credential_exfil"
|
assert observation.deny_reason == "credential_exfil"
|
||||||
|
|
||||||
|
|
||||||
|
def test_qonto_source_identity_and_egress_override_legacy_mapping_hints() -> None:
|
||||||
|
fixture = load_pilot()
|
||||||
|
event = dict(fixture.audit_event)
|
||||||
|
event["identity_binding"] = "key_cape_jwt"
|
||||||
|
event["egress_destination"] = "qonto-proxy"
|
||||||
|
|
||||||
|
observation = observation_from_fixture(fixture, event)
|
||||||
|
|
||||||
|
assert observation.identity_binding == "key_cape_jwt"
|
||||||
|
assert observation.egress_destination == "qonto-proxy"
|
||||||
|
|
||||||
|
|
||||||
def test_posture_evaluation_is_jsonable() -> None:
|
def test_posture_evaluation_is_jsonable() -> None:
|
||||||
fixture = load_pilot()
|
fixture = load_pilot()
|
||||||
observation = observation_from_fixture(fixture)
|
observation = observation_from_fixture(fixture)
|
||||||
|
|
@ -34,3 +46,11 @@ def test_posture_evaluation_is_jsonable() -> None:
|
||||||
assert payload["assessment"]["posture"] == PostureLevel.INFLAMED.value
|
assert payload["assessment"]["posture"] == PostureLevel.INFLAMED.value
|
||||||
assert payload["signals"][0]["signal_kind"] == "posture_hint"
|
assert payload["signals"][0]["signal_kind"] == "posture_hint"
|
||||||
assert payload["assessment"]["stream_completeness"] == "unknown"
|
assert payload["assessment"]["stream_completeness"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicitly_missing_source_context_does_not_recover_legacy_hints() -> None:
|
||||||
|
fixture = load_pilot()
|
||||||
|
event = dict(fixture.audit_event, identity_binding=None, egress_destination=None)
|
||||||
|
observation = observation_from_fixture(fixture, event)
|
||||||
|
assert observation.identity_binding is None
|
||||||
|
assert observation.egress_destination is None
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from kings_guard.contracts import StreamCompleteness
|
|
||||||
|
|
||||||
from helpers import load_pilot, observation_from_fixture
|
from helpers import load_pilot, observation_from_fixture
|
||||||
|
|
||||||
|
from kings_guard.contracts import StreamCompleteness
|
||||||
from kings_guard.posture import PostureEvaluator
|
from kings_guard.posture import PostureEvaluator
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
from helpers import load_pilot, observation_from_fixture
|
||||||
|
|
||||||
from kings_guard.adapters import observation_from_audit_event
|
from kings_guard.adapters import observation_from_audit_event
|
||||||
from kings_guard.contracts import DeclaredEvidenceSource, EvidenceClass
|
from kings_guard.contracts import DeclaredEvidenceSource, EvidenceClass
|
||||||
|
|
||||||
from helpers import load_pilot, observation_from_fixture
|
|
||||||
|
|
||||||
|
|
||||||
def test_genome_declares_both_evidence_classes() -> None:
|
def test_genome_declares_both_evidence_classes() -> None:
|
||||||
genome = load_pilot().genome
|
genome = load_pilot().genome
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Tooling-layer client. A test that only ran the checker against a clean tree
|
||||||
would prove nothing — it would pass just as happily if the checker were broken.
|
would prove nothing — it would pass just as happily if the checker were broken.
|
||||||
So the negative case is exercised too, on a synthetic tree.
|
So the negative case is exercised too, on a synthetic tree.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from helpers import load_pilot, observation_from_fixture
|
||||||
|
|
||||||
|
from kings_guard.cadence import load_qonto_assistant_source_cadence
|
||||||
|
from kings_guard.contracts import StreamCompleteness
|
||||||
from kings_guard.live import capture_qonto_assistant_events, qonto_assistant_available
|
from kings_guard.live import capture_qonto_assistant_events, qonto_assistant_available
|
||||||
from kings_guard.posture import PostureEvaluator
|
from kings_guard.posture import PostureEvaluator
|
||||||
|
|
||||||
from helpers import load_pilot, observation_from_fixture
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.skipif(
|
pytestmark = pytest.mark.skipif(
|
||||||
not qonto_assistant_available(),
|
not qonto_assistant_available(),
|
||||||
reason="qonto-assistant checkout is required for live observation",
|
reason="qonto-assistant checkout is required for live observation",
|
||||||
|
|
@ -28,7 +29,10 @@ def test_real_emitted_qonto_events_reach_the_evaluator() -> None:
|
||||||
assert "allow" in decisions
|
assert "allow" in decisions
|
||||||
assert "deny" in decisions
|
assert "deny" in decisions
|
||||||
assert capture.mapping_notes
|
assert capture.mapping_notes
|
||||||
assert all(observation.source_system == "qonto-assistant" for observation in capture.observations)
|
assert capture.stream_notes
|
||||||
|
assert all(
|
||||||
|
observation.source_system == "qonto-assistant" for observation in capture.observations
|
||||||
|
)
|
||||||
|
|
||||||
deny = next(item for item in capture.observations if item.decision.value == "deny")
|
deny = next(item for item in capture.observations if item.decision.value == "deny")
|
||||||
evaluation = PostureEvaluator().evaluate(fixture.genome, deny)
|
evaluation = PostureEvaluator().evaluate(fixture.genome, deny)
|
||||||
|
|
@ -39,8 +43,25 @@ def test_real_emitted_qonto_events_reach_the_evaluator() -> None:
|
||||||
for request in signal.effector_requests:
|
for request in signal.effector_requests:
|
||||||
assert request.authority_boundary in {"advisory_only", "metadata_only"}
|
assert request.authority_boundary in {"advisory_only", "metadata_only"}
|
||||||
|
|
||||||
# Mapping confirmed against the real emit path; remaining gaps are source omissions,
|
# The source now emits identity binding and egress directly. The adapter must
|
||||||
# not adapter drift.
|
# preserve those values instead of masking them with its legacy mapping hints.
|
||||||
assert any("identity_binding" in item for item in capture.corrections_for_source)
|
for event, observation in zip(capture.events, capture.observations, strict=True):
|
||||||
assert any("egress_destination" in item for item in capture.corrections_for_source)
|
assert observation.identity_binding == event["identity_binding"]
|
||||||
assert not any("mapping drifted" in item for item in capture.corrections_for_source)
|
assert observation.egress_destination == event["egress_destination"]
|
||||||
|
assert capture.corrections_for_source == ()
|
||||||
|
assert capture.stream_corrections == ()
|
||||||
|
|
||||||
|
cadence = load_qonto_assistant_source_cadence()
|
||||||
|
stream_evaluation = PostureEvaluator().evaluate_with_stream(
|
||||||
|
fixture.genome,
|
||||||
|
deny,
|
||||||
|
cadence,
|
||||||
|
now=capture.heartbeats[-1].timestamp,
|
||||||
|
watching_since=capture.heartbeats[0].timestamp,
|
||||||
|
observations=capture.observations,
|
||||||
|
heartbeats=capture.heartbeats,
|
||||||
|
reconciliation=capture.reconciliation,
|
||||||
|
)
|
||||||
|
assert stream_evaluation.assessment.stream_completeness is StreamCompleteness.COMPLETE
|
||||||
|
assert stream_evaluation.stream is not None
|
||||||
|
assert stream_evaluation.stream.findings == ()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from kings_guard.posture import PostureEvaluator
|
|
||||||
|
|
||||||
from helpers import load_pilot, observation_from_fixture
|
from helpers import load_pilot, observation_from_fixture
|
||||||
|
|
||||||
|
from kings_guard.posture import PostureEvaluator
|
||||||
|
|
||||||
|
|
||||||
def test_qonto_pilot_produces_inflamed_posture_with_tolerance_context() -> None:
|
def test_qonto_pilot_produces_inflamed_posture_with_tolerance_context() -> None:
|
||||||
fixture = load_pilot()
|
fixture = load_pilot()
|
||||||
|
|
|
||||||
83
tests/test_secrets_engine_snapshot.py
Normal file
83
tests/test_secrets_engine_snapshot.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from kings_guard.adapters.secrets_engine import snapshot_from_engine
|
||||||
|
from kings_guard.contracts import StreamCompleteness
|
||||||
|
|
||||||
|
NOW = "2026-09-05T12:00:00Z"
|
||||||
|
PAYLOAD = {
|
||||||
|
"surface": "secret-use-evidence", "as_of": NOW, "completeness_claimed": False,
|
||||||
|
"cadence": {"form": "heartbeat", "interval": "1d"},
|
||||||
|
"lanes": [{"as_of": NOW, "catalog_id": "lane-1", "stage": "test", "kind": "kv"}],
|
||||||
|
}
|
||||||
|
BINDINGS = {"lane-1": ("tenant-1", "subject-1")}
|
||||||
|
|
||||||
|
|
||||||
|
def admit(payload, now=NOW, bindings=BINDINGS):
|
||||||
|
return snapshot_from_engine(payload, lane_bindings=bindings, now=now)
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_only_snapshot_does_not_invent_evidence_or_completeness():
|
||||||
|
result = admit(PAYLOAD)
|
||||||
|
assert result.lanes[0].ready is None
|
||||||
|
assert result.lanes[0].revocation_succeeded is None
|
||||||
|
assert result.lanes[0].evidence_kind is None
|
||||||
|
assert result.completeness is StreamCompleteness.UNKNOWN
|
||||||
|
assert result.heartbeat_interval.total_seconds() == 86400
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_preserves_false_and_drops_authorization_and_sensitive_fields():
|
||||||
|
payload = deepcopy(PAYLOAD)
|
||||||
|
payload["lanes"][0].update(ready=False, revocation_succeeded=False,
|
||||||
|
decision_id="dec-1", session_handle="session-1",
|
||||||
|
secret="never-retained", lifecycle_operation="revoke")
|
||||||
|
result = admit(payload)
|
||||||
|
assert result.lanes[0].ready is False
|
||||||
|
assert result.lanes[0].revocation_succeeded is False
|
||||||
|
assert result.lanes[0].lifecycle_operation == "revoke"
|
||||||
|
fields = asdict(result.lanes[0])
|
||||||
|
assert {"decision_id", "session_handle", "secret"}.isdisjoint(fields)
|
||||||
|
assert result.completeness is StreamCompleteness.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("now", "findings"), [
|
||||||
|
("2026-09-06T12:00:00Z", ()),
|
||||||
|
("2026-09-06T12:00:01Z", ("snapshot:stale",)),
|
||||||
|
("2026-09-05T11:59:59Z", ("snapshot:future_timestamp",)),
|
||||||
|
])
|
||||||
|
def test_freshness_does_not_upgrade_completeness(now, findings):
|
||||||
|
result = admit(PAYLOAD, now)
|
||||||
|
assert result.findings == findings
|
||||||
|
assert result.completeness is StreamCompleteness.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbound_or_duplicate_lanes_cannot_cross_scope():
|
||||||
|
with pytest.raises(ValueError, match="unbound"):
|
||||||
|
admit(PAYLOAD, bindings={})
|
||||||
|
payload = deepcopy(PAYLOAD)
|
||||||
|
payload["lanes"].append(payload["lanes"][0])
|
||||||
|
with pytest.raises(ValueError, match="duplicate"):
|
||||||
|
admit(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("change", [
|
||||||
|
{"completeness_claimed": True}, {"completeness_claimed": None},
|
||||||
|
{"cadence": {"form": "heartbeat", "interval": "0s"}},
|
||||||
|
{"surface": "authorization"}, {"lanes": None},
|
||||||
|
])
|
||||||
|
def test_invalid_envelope_is_rejected(change):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
admit({**PAYLOAD, **change})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("change", [
|
||||||
|
{"ready": "false"}, {"revocation_attempted": 0},
|
||||||
|
{"as_of": "2026-09-04T12:00:00Z"}, {"evidence_kind": "guessed"},
|
||||||
|
])
|
||||||
|
def test_invalid_lane_is_rejected(change):
|
||||||
|
payload = deepcopy(PAYLOAD)
|
||||||
|
payload["lanes"][0].update(change)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
admit(payload)
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from helpers import load_pilot, observation_from_fixture
|
||||||
|
|
||||||
from kings_guard.cadence import load_qonto_assistant_cadence
|
from kings_guard.cadence import load_qonto_assistant_cadence
|
||||||
from kings_guard.contracts import (
|
from kings_guard.contracts import (
|
||||||
ReconciliationView,
|
ReconciliationView,
|
||||||
|
|
@ -7,8 +9,6 @@ from kings_guard.contracts import (
|
||||||
)
|
)
|
||||||
from kings_guard.posture import PostureEvaluator, is_stream_finding
|
from kings_guard.posture import PostureEvaluator, is_stream_finding
|
||||||
|
|
||||||
from helpers import load_pilot, observation_from_fixture
|
|
||||||
|
|
||||||
|
|
||||||
def test_cadence_draft_covers_both_forms_against_qonto() -> None:
|
def test_cadence_draft_covers_both_forms_against_qonto() -> None:
|
||||||
cadence = load_qonto_assistant_cadence()
|
cadence = load_qonto_assistant_cadence()
|
||||||
|
|
@ -36,7 +36,9 @@ def test_unmet_declared_rate_is_a_stream_finding() -> None:
|
||||||
counts={"audit.deny": 0},
|
counts={"audit.deny": 0},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
reconciliation=ReconciliationView(source_counts={"audit.deny": 0}, evidence_counts={"audit.deny": 0}),
|
reconciliation=ReconciliationView(
|
||||||
|
source_counts={"audit.deny": 0}, evidence_counts={"audit.deny": 0}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert any(item.startswith("stream:cadence_unmet:audit.allow") for item in stream.findings)
|
assert any(item.startswith("stream:cadence_unmet:audit.allow") for item in stream.findings)
|
||||||
|
|
@ -54,7 +56,9 @@ def test_missing_heartbeat_is_a_stream_finding() -> None:
|
||||||
now="2026-07-24T10:10:00Z",
|
now="2026-07-24T10:10:00Z",
|
||||||
watching_since="2026-07-22T09:10:00Z",
|
watching_since="2026-07-22T09:10:00Z",
|
||||||
heartbeats=(),
|
heartbeats=(),
|
||||||
reconciliation=ReconciliationView(source_counts={"audit.deny": 1}, evidence_counts={"audit.deny": 1}),
|
reconciliation=ReconciliationView(
|
||||||
|
source_counts={"audit.deny": 1}, evidence_counts={"audit.deny": 1}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert any(item.startswith("stream:heartbeat_missing:audit.deny") for item in stream.findings)
|
assert any(item.startswith("stream:heartbeat_missing:audit.deny") for item in stream.findings)
|
||||||
|
|
@ -103,7 +107,9 @@ def test_stream_findings_are_distinguishable_from_content_findings() -> None:
|
||||||
now="2026-07-24T10:10:00Z",
|
now="2026-07-24T10:10:00Z",
|
||||||
watching_since="2026-07-22T09:10:00Z",
|
watching_since="2026-07-22T09:10:00Z",
|
||||||
heartbeats=(),
|
heartbeats=(),
|
||||||
reconciliation=ReconciliationView(source_counts={"audit.deny": 1}, evidence_counts={"audit.deny": 1}),
|
reconciliation=ReconciliationView(
|
||||||
|
source_counts={"audit.deny": 1}, evidence_counts={"audit.deny": 1}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
content_findings = set(evaluation.assessment.findings)
|
content_findings = set(evaluation.assessment.findings)
|
||||||
|
|
@ -127,7 +133,9 @@ def test_heartbeat_not_yet_due_does_not_false_alarm() -> None:
|
||||||
now=observation.timestamp,
|
now=observation.timestamp,
|
||||||
watching_since=observation.timestamp,
|
watching_since=observation.timestamp,
|
||||||
heartbeats=(),
|
heartbeats=(),
|
||||||
reconciliation=ReconciliationView(source_counts={"audit.deny": 1}, evidence_counts={"audit.deny": 1}),
|
reconciliation=ReconciliationView(
|
||||||
|
source_counts={"audit.deny": 1}, evidence_counts={"audit.deny": 1}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert not any(item.startswith("stream:heartbeat_missing") for item in stream.findings)
|
assert not any(item.startswith("stream:heartbeat_missing") for item in stream.findings)
|
||||||
|
|
|
||||||
88
tests/test_stream_admission.py
Normal file
88
tests/test_stream_admission.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from kings_guard.cadence import load_qonto_assistant_source_cadence
|
||||||
|
from kings_guard.contracts import ReconciliationView, StreamCompleteness, StreamHeartbeat
|
||||||
|
from kings_guard.live import _confirm_stream
|
||||||
|
from kings_guard.stream import evaluate_stream
|
||||||
|
|
||||||
|
NOW = "2026-09-05T12:00:00Z"
|
||||||
|
COUNTS = {"audit.allow": 0, "audit.deny": 0}
|
||||||
|
EVENT = {
|
||||||
|
"stream_id": "qonto.audit", "stream_instance_id": "instance-1", "stream_sequence": 1,
|
||||||
|
"event_class": "audit.heartbeat", "timestamp": NOW, "assertion": "nothing-to-report",
|
||||||
|
"source_transition_counts": COUNTS,
|
||||||
|
}
|
||||||
|
SNAPSHOT = {
|
||||||
|
"stream_id": "qonto.audit", "stream_instance_id": "instance-1",
|
||||||
|
"last_stream_sequence": 1, "source_transition_counts": COUNTS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("change", [
|
||||||
|
{"stream_instance_id": "instance-2"}, {"stream_id": "other.audit"},
|
||||||
|
{"last_stream_sequence": 2}, {"last_stream_sequence": True},
|
||||||
|
])
|
||||||
|
def test_reconciliation_must_belong_to_captured_stream(change):
|
||||||
|
assert _confirm_stream([EVENT], {**SNAPSHOT, **change})[-1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sequence", [None, True, "1", -1])
|
||||||
|
def test_malformed_sequences_are_rejected(sequence):
|
||||||
|
with pytest.raises(ValueError, match="stream_sequence"):
|
||||||
|
_confirm_stream([{**EVENT, "stream_sequence": sequence}], SNAPSHOT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_capture_is_rejected_without_index_error():
|
||||||
|
with pytest.raises(ValueError, match="empty"):
|
||||||
|
_confirm_stream([], SNAPSHOT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_counts_cannot_be_normalized_to_zero():
|
||||||
|
snapshot = deepcopy(SNAPSHOT)
|
||||||
|
snapshot["source_transition_counts"] = {"audit.allow": 0}
|
||||||
|
with pytest.raises(ValueError, match="counts"):
|
||||||
|
_confirm_stream([EVENT], snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("counts", [{}, {"audit.deny": -1}, {"audit.deny": True}])
|
||||||
|
def test_absent_or_invalid_reconciliation_counts_leave_completeness_unknown(counts):
|
||||||
|
result = evaluate_stream(
|
||||||
|
(), load_qonto_assistant_source_cadence(), now=NOW,
|
||||||
|
heartbeats=[StreamHeartbeat("qonto-assistant", NOW, "audit.heartbeat",
|
||||||
|
"nothing-to-report")],
|
||||||
|
reconciliation=ReconciliationView(counts, counts),
|
||||||
|
)
|
||||||
|
assert result.completeness is StreamCompleteness.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("source", "timestamp"), [
|
||||||
|
("other-source", NOW), ("qonto-assistant", "2026-09-07T12:00:00Z"),
|
||||||
|
])
|
||||||
|
def test_unrelated_or_future_heartbeat_cannot_satisfy_cadence(source, timestamp):
|
||||||
|
result = evaluate_stream(
|
||||||
|
(), load_qonto_assistant_source_cadence(), now=NOW,
|
||||||
|
watching_since="2026-09-03T12:00:00Z",
|
||||||
|
heartbeats=[StreamHeartbeat(source, timestamp, "audit.heartbeat", "nothing-to-report")],
|
||||||
|
reconciliation=ReconciliationView(COUNTS, COUNTS),
|
||||||
|
)
|
||||||
|
assert result.completeness is StreamCompleteness.DEGRADED
|
||||||
|
assert "stream:heartbeat_missing:audit.deny" in result.findings
|
||||||
|
|
||||||
|
|
||||||
|
def test_excess_evidence_is_also_reconciliation_divergence():
|
||||||
|
result = evaluate_stream(
|
||||||
|
(), load_qonto_assistant_source_cadence(), now=NOW,
|
||||||
|
heartbeats=[StreamHeartbeat("qonto-assistant", NOW, "audit.heartbeat",
|
||||||
|
"nothing-to-report")],
|
||||||
|
reconciliation=ReconciliationView({"audit.deny": 0}, {"audit.deny": 1}),
|
||||||
|
)
|
||||||
|
assert "stream:reconciliation_divergence:audit.deny" in result.findings
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_counts_must_match_events_already_captured():
|
||||||
|
event = {**EVENT, "source_transition_counts": {"audit.allow": 0, "audit.deny": 1}}
|
||||||
|
assert "heartbeat counts do not match preceding request events" in (
|
||||||
|
_confirm_stream([event], SNAPSHOT)[-1]
|
||||||
|
)
|
||||||
34
workplans/ADHOC-2026-09-04.md
Normal file
34
workplans/ADHOC-2026-09-04.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
id: KG-WP-ADHOC-2026-09-04
|
||||||
|
type: workplan
|
||||||
|
title: "2026-09-04 repository consistency maintenance"
|
||||||
|
domain: infotech
|
||||||
|
repo: kings-guard
|
||||||
|
status: finished
|
||||||
|
owner: codex
|
||||||
|
topic_slug: netkingdom
|
||||||
|
created: "2026-09-04"
|
||||||
|
updated: "2026-09-04"
|
||||||
|
finished: "2026-09-04"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-09-04 Repository Consistency Maintenance
|
||||||
|
|
||||||
|
## Reconcile the accepted layer declaration and the shipped secret-use surface
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KG-WP-ADHOC-2026-09-04-T01
|
||||||
|
status: done
|
||||||
|
priority: low
|
||||||
|
```
|
||||||
|
|
||||||
|
The completed KG-WP-0004 sweep left current boundary prose and checker
|
||||||
|
commentary pointing at v0.6 while the authoritative declaration and architecture
|
||||||
|
already use accepted v0.7. Align those references and make `SCOPE.md` say
|
||||||
|
explicitly that containment is proposed rather than owned.
|
||||||
|
|
||||||
|
Also consume the 2026-09-04 coordination update from `secrets-engine`: record
|
||||||
|
that its non-secret `secret-use snapshot` surface and 1d heartbeat declaration
|
||||||
|
have shipped. Observation-input admission changes a posture lane and requires
|
||||||
|
mapping and contract decisions, so hand that residual to live intake
|
||||||
|
`KG-IN-0004` rather than silently treating the unmodeled surface as consumed.
|
||||||
71
workplans/KG-WP-0005-qonto-source-cadence-admission.md
Normal file
71
workplans/KG-WP-0005-qonto-source-cadence-admission.md
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
---
|
||||||
|
id: KG-WP-0005
|
||||||
|
type: workplan
|
||||||
|
title: "Admit and validate qonto-assistant source completeness evidence"
|
||||||
|
domain: infotech
|
||||||
|
repo: kings-guard
|
||||||
|
status: active
|
||||||
|
owner: codex
|
||||||
|
topic_slug: netkingdom
|
||||||
|
created: "2026-09-04"
|
||||||
|
updated: "2026-09-05"
|
||||||
|
origin: cross-repo
|
||||||
|
origin_ref: QONTO-WP-0005
|
||||||
|
---
|
||||||
|
|
||||||
|
# Admit and Validate Qonto-Assistant Source Completeness Evidence
|
||||||
|
|
||||||
|
Qonto-assistant has shipped the source-side contract requested by KG-WP-0003:
|
||||||
|
source-owned cadence, instance and sequence fields, startup/periodic/shutdown
|
||||||
|
heartbeats, and a reconciliation view. Kings-guard must consume and validate
|
||||||
|
those claims without confusing source transition counts with authorization or
|
||||||
|
claiming completeness beyond an observed process instance.
|
||||||
|
|
||||||
|
## Consume source-emitted observation context
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KG-WP-0005-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer source-emitted `identity_binding` and `egress_destination` over legacy
|
||||||
|
normalization hints, retain the hints for old records, and prove the live
|
||||||
|
mapping does not drift.
|
||||||
|
|
||||||
|
## Validate cadence, heartbeat, sequence, and reconciliation locally
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KG-WP-0005-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Consume the source-owned cadence separately from kings-guard's Taxonomy draft.
|
||||||
|
Drive qonto-assistant's real AuditLogger emit path, require one stream instance
|
||||||
|
with contiguous sequence numbers, translate heartbeat claims, compare source
|
||||||
|
transition counts with captured request evidence, and prove the resulting deny
|
||||||
|
stream evaluates complete without findings.
|
||||||
|
|
||||||
|
## Validate the deployed stream
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KG-WP-0005-T03
|
||||||
|
status: wait
|
||||||
|
priority: medium
|
||||||
|
```
|
||||||
|
|
||||||
|
Observe an authorized deployed qonto-assistant process from startup through at
|
||||||
|
least one allow/deny transition and reconciliation read. Confirm the deployed
|
||||||
|
configuration emits periodic heartbeats at the declared interval, preserves
|
||||||
|
sequence/instance semantics, and produces no unexplained divergence. This is
|
||||||
|
operational evidence; the local source-path proof does not substitute for it.
|
||||||
|
|
||||||
|
**Review 2026-09-05:** Local source-path tests pass with source context,
|
||||||
|
heartbeat counters, sequence and reconciliation checks. The canonical cadence
|
||||||
|
fixture migration and rejection-path hardening are tracked in KG-WP-0006.
|
||||||
|
T03 remains waiting: the source workplan explicitly leaves deployment with the
|
||||||
|
runtime owner; this repository supplies no authorized deployed stream endpoint
|
||||||
|
or process-lifecycle observation route. Required input is a runtime-owner
|
||||||
|
capture from startup, a request transition, periodic heartbeat timing, and a
|
||||||
|
same-instance reconciliation snapshot. No cluster or Tooling client was used.
|
||||||
77
workplans/KG-WP-0006-observation-input-review.md
Normal file
77
workplans/KG-WP-0006-observation-input-review.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
---
|
||||||
|
id: KG-WP-0006
|
||||||
|
type: workplan
|
||||||
|
title: "Review and admit canonical cadence and secret-use snapshot inputs"
|
||||||
|
domain: infotech
|
||||||
|
repo: kings-guard
|
||||||
|
status: finished
|
||||||
|
owner: codex
|
||||||
|
topic_slug: netkingdom
|
||||||
|
created: "2026-09-05"
|
||||||
|
updated: "2026-09-05"
|
||||||
|
origin: cross-repo
|
||||||
|
origin_ref: KG-IN-0004
|
||||||
|
---
|
||||||
|
|
||||||
|
# Observation input review
|
||||||
|
|
||||||
|
## Migrate the cadence consumer to the published generic contract
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KG-WP-0006-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Consume InfoTechCanon standard/emission-cadence 0.1. Move profile and
|
||||||
|
provenance fields into namespaced extensions; validate examples against the
|
||||||
|
owner's schema without copying it. Keep the handover draft historical.
|
||||||
|
|
||||||
|
## Harden Qonto stream admission and completeness checks
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KG-WP-0006-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Reject mismatched instances, malformed sequences and counters; preserve missing
|
||||||
|
context; prevent absent reconciliation fields or unrelated/future heartbeats
|
||||||
|
from manufacturing completeness. Cover failure paths with regression tests.
|
||||||
|
|
||||||
|
## Admit secret-use snapshots with explicit scope and unknown completeness
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KG-WP-0006-T03
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
```
|
||||||
|
|
||||||
|
Map the Engine envelope into a separate snapshot observation: it has no event
|
||||||
|
actor, event timestamp, or authorization outcome. Require explicit lane scope,
|
||||||
|
retain only typed metadata, preserve omitted evidence fields, translate the
|
||||||
|
heartbeat interval as a freshness bound, and never promote snapshot freshness
|
||||||
|
to stream completeness. Record residual source evidence needed for posture.
|
||||||
|
|
||||||
|
## Completion evidence
|
||||||
|
|
||||||
|
Reviewed the original pending Qonto implementation and added negative-path
|
||||||
|
coverage for scoped streams, absent/invalid counters, snapshot instance binding,
|
||||||
|
sequence validation, and heartbeat reconciliation. Both migrated fixtures pass
|
||||||
|
InfoTechCanon's external schema 0.1. Snapshot mapping and limitations are in
|
||||||
|
`docs/SecretUseSnapshotAdmission.md`.
|
||||||
|
|
||||||
|
Residual KG-IN-0005 was recorded before completion for secret-use event
|
||||||
|
provenance and operational evidence. Deployed Qonto acceptance remains explicitly
|
||||||
|
waiting in KG-WP-0005-T03; local source-path tests do not close it. The NetKingdom
|
||||||
|
profile remains proposed in its owner's repository; no adoption is asserted.
|
||||||
|
|
||||||
|
Validation: 78 tests passed (including external canonical schema and local
|
||||||
|
Qonto emit-path tests), lint and layer conformance passed, pilot demo passed,
|
||||||
|
and `git diff --check` was clean. Decision: KG-DEC-2026-003.
|
||||||
|
|
||||||
|
State Hub: decision KG-DEC-2026-003 was recorded successfully. Two required
|
||||||
|
`statehub fix-consistency --repo kings-guard` attempts (including an unrestricted
|
||||||
|
retry) failed with C-00: repository API lookup timed out. Workplan/intake files
|
||||||
|
remain authoritative; the operator must rerun synchronization when the repository
|
||||||
|
lookup is available. This is a synchronization failure, not implementation evidence.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue