Implement AUDIT-WP-0006 honest operational custody.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Postgres now reports custody_class=operational with a cited 30-day
recoverable window. Join ITC-CAP operations.audit at D4, publish the
interface card, and overlay user-engine tenants [*] from Git so an
ExternalSecret refresh cannot shrink it.
This commit is contained in:
tegwick 2026-08-16 00:24:33 +02:00
parent 0a3d05ff1c
commit ded432a63f
25 changed files with 832 additions and 94 deletions

View file

@ -45,6 +45,7 @@ image-publish: image-build ## Push the commit-tagged image to Forgejo
deploy-dry-run: ## Server-side validate the railiance01 manifests deploy-dry-run: ## Server-side validate the railiance01 manifests
KUBECONFIG=$(KUBECONFIG_RAILIANCE) kubectl apply --dry-run=server --validate=strict \ KUBECONFIG=$(KUBECONFIG_RAILIANCE) kubectl apply --dry-run=server --validate=strict \
-f deploy/audit-core.yaml \ -f deploy/audit-core.yaml \
-f deploy/senders-scope.yaml \
-f deploy/networkpolicies.yaml \ -f deploy/networkpolicies.yaml \
-f deploy/clustersecretstore.yaml \ -f deploy/clustersecretstore.yaml \
-f deploy/externalsecrets.yaml \ -f deploy/externalsecrets.yaml \

View file

@ -15,21 +15,28 @@ audit-core exists to provide the capability described in INTENT.md.
- Maintain the repository's primary implementation. - Maintain the repository's primary implementation.
- Keep docs, tests, and operational metadata current. - Keep docs, tests, and operational metadata current.
- Operational audit custody (`operations.audit`) and its declared recovery bound.
## Out of Scope ## Out of Scope
- Own unrelated adjacent systems. - Own unrelated adjacent systems.
- Make irreversible operational decisions without human approval. - Make irreversible operational decisions without human approval.
- Procuring or operating S3 / Barman / WAL.
- Booked cost or a second usage stream for `platform:audit-storage`.
- A `rapp.yaml` in this repo (schema requires `rapp-*`).
- Public ingest.
## Current State ## Current State
- Status: active - Status: production
- Production receiver on railiance01 (`namespace audit-core`), Postgres - Production receiver on railiance01 (`namespace audit-core`), Postgres
append-only store on `platform-pg`, sender `user-engine`. operational custody on `platform-pg`, sender `user-engine`.
- Recovery is the platform `data.backup` window (30 days, RESOURCE-WP-0002 - Recovery is the platform `data.backup` window (30 days, RESOURCE-WP-0002
live), not an audit-core deletion window. Production still reports live). Manifest `/readyz` reports `custody_class=operational` and
`custody_class=archive`; AUDIT-WP-0006 is the honesty/canon-join pass. `recoverable_days=30` once the AUDIT-WP-0006 image is pinned.
- Open workplan: `workplans/AUDIT-WP-0006-honest-custody-and-canon-join.md`. - ITC-CAP case: `data/capability/audit-core-operational.json`.
`data.archive` is an unmet requirement.
- AUDIT-WP-0001…0006 closed. No open workplan.
## Getting Oriented ## Getting Oriented

View file

@ -13,6 +13,7 @@ from audit_core.interface import (
IdempotentAuditBackend, IdempotentAuditBackend,
RetentionPolicy, RetentionPolicy,
SCHEMA_VERSION_V1ALPHA1, SCHEMA_VERSION_V1ALPHA1,
custody_class_satisfies,
validate_event, validate_event,
) )
from audit_core.mock_file_backend import MockFileAuditBackend from audit_core.mock_file_backend import MockFileAuditBackend
@ -30,5 +31,6 @@ __all__ = [
"RetentionPolicy", "RetentionPolicy",
"SCHEMA_VERSION_V1ALPHA1", "SCHEMA_VERSION_V1ALPHA1",
"SQLiteAuditBackend", "SQLiteAuditBackend",
"custody_class_satisfies",
"validate_event", "validate_event",
] ]

View file

@ -38,6 +38,7 @@ from audit_core.interface import (
EventConflictError, EventConflictError,
EventValidationError, EventValidationError,
IdempotentAuditBackend, IdempotentAuditBackend,
custody_class_satisfies,
) )
from audit_core.redaction import ( from audit_core.redaction import (
POLICY_REDACT, POLICY_REDACT,
@ -102,10 +103,13 @@ class IngestionApplication:
require_custody_class: str | None = None, require_custody_class: str | None = None,
) -> None: ) -> None:
policy = backend.retention_policy policy = backend.retention_policy
if require_custody_class and policy.custody_class != require_custody_class: if require_custody_class and not custody_class_satisfies(
policy.custody_class, require_custody_class
):
# Production sets this. Without it, losing AUDIT_CORE_DATABASE_URL # Production sets this. Without it, losing AUDIT_CORE_DATABASE_URL
# silently downgrades custody to the development store instead of # silently downgrades custody to the development store instead of
# failing to start. # failing to start. ``operational`` and ``archive`` alias each
# other for one mixed-rollout deploy (AUDIT-WP-0006-T01).
raise ValueError( raise ValueError(
f"backend custody_class={policy.custody_class!r} does not meet the " f"backend custody_class={policy.custody_class!r} does not meet the "
f"required {require_custody_class!r}; refusing to start" f"required {require_custody_class!r}; refusing to start"
@ -330,11 +334,10 @@ class IngestionApplication:
return self._json( return self._json(
start_response, HTTPStatus.SERVICE_UNAVAILABLE, {"status": "unavailable"} start_response, HTTPStatus.SERVICE_UNAVAILABLE, {"status": "unavailable"}
) )
policy = self.backend.retention_policy
return self._json( return self._json(
start_response, start_response,
HTTPStatus.OK, HTTPStatus.OK,
{"status": "ok", "custody_class": policy.custody_class, "durable": policy.durable}, self.backend.retention_policy.as_readiness(),
) )
@staticmethod @staticmethod
@ -490,11 +493,15 @@ def build_backend() -> IdempotentAuditBackend:
else "AUDIT_CORE_DATABASE_URL" if url else "AUDIT_CORE_DATABASE_URL" if url
else "brokered libpq environment") else "brokered libpq environment")
log.info("custody backend: postgresql (%s)", source) log.info("custody backend: postgresql (%s)", source)
recoverable = os.environ.get("AUDIT_CORE_RECOVERABLE_DAYS")
return PostgresAuditBackend( return PostgresAuditBackend(
url or "", url or "",
credential_dir=credential_dir, credential_dir=credential_dir,
schema=os.environ.get("AUDIT_CORE_DATABASE_SCHEMA", "audit_core"), schema=os.environ.get("AUDIT_CORE_DATABASE_SCHEMA", "audit_core"),
retention_days=int(retention) if retention else None, retention_days=int(retention) if retention else None,
recoverable_days=(
int(recoverable) if recoverable else 30
),
max_size=int(os.environ.get("AUDIT_CORE_DB_POOL_MAX", "8")), max_size=int(os.environ.get("AUDIT_CORE_DB_POOL_MAX", "8")),
statement_timeout_ms=int( statement_timeout_ms=int(
os.environ.get("AUDIT_CORE_DB_STATEMENT_TIMEOUT_MS", "30000") os.environ.get("AUDIT_CORE_DB_STATEMENT_TIMEOUT_MS", "30000")

View file

@ -13,7 +13,23 @@ from uuid import uuid4
SCHEMA_VERSION_V1ALPHA1 = "audit-core.event.v1alpha1" SCHEMA_VERSION_V1ALPHA1 = "audit-core.event.v1alpha1"
CustodyClass = Literal["development", "archive", "hot_search"] CustodyClass = Literal["development", "operational", "archive", "hot_search"]
# Production Postgres reports ``operational``. Manifests written before
# AUDIT-WP-0006 required ``archive``. The two are aliases for one deploy so
# a mixed rollout cannot refuse to start. ``development`` is never an alias.
_PRODUCTION_CUSTODY_CLASSES = frozenset({"operational", "archive"})
def custody_class_satisfies(actual: str, required: str) -> bool:
"""Whether a backend's class meets a startup requirement.
``operational`` and ``archive`` satisfy each other. A development
backend satisfies only ``development``.
"""
if actual == required:
return True
return {actual, required} <= _PRODUCTION_CUSTODY_CLASSES
_REQUIRED_STRING_FIELDS = ( _REQUIRED_STRING_FIELDS = (
"schema_version", "schema_version",
@ -41,6 +57,24 @@ class RetentionPolicy:
immutable: bool immutable: bool
tamper_evidence: bool tamper_evidence: bool
durable: bool durable: bool
# Recoverable history is the platform backup window, not a deletion
# policy. ``None`` means not declared (development backends).
recoverable_days: int | None = None
recoverable_source: str | None = None
recoverable_basis: str | None = None
def as_readiness(self) -> dict[str, Any]:
"""Sender-visible /readyz body. Keeps ``custody_class`` and adds recovery."""
payload: dict[str, Any] = {
"status": "ok",
"custody_class": self.custody_class,
"durable": self.durable,
}
if self.recoverable_days is not None or self.recoverable_source:
payload["recoverable_days"] = self.recoverable_days
payload["recoverable_source"] = self.recoverable_source
payload["recoverable_basis"] = self.recoverable_basis
return payload
@dataclass(frozen=True) @dataclass(frozen=True)
@ -142,7 +176,7 @@ def validate_event(event: AuditEvent) -> None:
class AuditBackend(Protocol): class AuditBackend(Protocol):
"""Protocol implemented by replaceable audit sinks. """Protocol implemented by replaceable audit sinks.
Production backends provide durable archive or hot-search custody. Production backends provide durable operational or archive custody.
Development backends (such as :class:`~audit_core.mock_file_backend.MockFileAuditBackend`) Development backends (such as :class:`~audit_core.mock_file_backend.MockFileAuditBackend`)
are for wiring only and must not be treated as audit custody. are for wiring only and must not be treated as audit custody.
""" """

View file

@ -151,6 +151,12 @@ class PostgresAuditBackend:
*, *,
schema: str = DEFAULT_SCHEMA, schema: str = DEFAULT_SCHEMA,
retention_days: int | None = None, retention_days: int | None = None,
recoverable_days: int | None = 30,
recoverable_source: str | None = (
"resource-control/data/capability/platform-audit-storage.json"
"#provisions[capability=data.backup]"
),
recoverable_basis: str | None = "measured",
min_size: int = 1, min_size: int = 1,
max_size: int = 8, max_size: int = 8,
statement_timeout_ms: int = 30_000, statement_timeout_ms: int = 30_000,
@ -178,6 +184,9 @@ class PostgresAuditBackend:
raise ValueError(f"unsafe schema name: {schema!r}") raise ValueError(f"unsafe schema name: {schema!r}")
self.schema = schema self.schema = schema
self.retention_days = retention_days self.retention_days = retention_days
self.recoverable_days = recoverable_days
self.recoverable_source = recoverable_source
self.recoverable_basis = recoverable_basis
base_kwargs = { base_kwargs = {
"autocommit": True, "autocommit": True,
# A stalled write must surface as unavailable rather than hold a # A stalled write must surface as unavailable rather than hold a
@ -266,13 +275,22 @@ class PostgresAuditBackend:
who can drop the trigger; ``tamper_evidence`` is correspondingly False, who can drop the trigger; ``tamper_evidence`` is correspondingly False,
because nothing here would *prove* they had. Hash-chaining or external because nothing here would *prove* they had. Hash-chaining or external
anchoring would be needed for that, and is not implemented. anchoring would be needed for that, and is not implemented.
``custody_class`` is ``operational``, not ``archive``. This store is
durable append-only Postgres recovered through the platform
``data.backup`` provision. It is not ITC-CAP ``data.archive`` (WORM
object storage, manifests, retrieval tests). Recoverable history is
the cited platform window, not ``retention_days``.
""" """
return RetentionPolicy( return RetentionPolicy(
custody_class="archive", custody_class="operational",
retention_days=self.retention_days, retention_days=self.retention_days,
immutable=True, immutable=True,
tamper_evidence=False, tamper_evidence=False,
durable=True, durable=True,
recoverable_days=self.recoverable_days,
recoverable_source=self.recoverable_source,
recoverable_basis=self.recoverable_basis,
) )
def emit(self, event: AuditEvent) -> str: def emit(self, event: AuditEvent) -> str:

View file

@ -17,6 +17,7 @@ import hmac
import json import json
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
from audit_core.redaction import POLICIES, POLICY_REDACT from audit_core.redaction import POLICIES, POLICY_REDACT
@ -113,7 +114,7 @@ class SenderRegistry:
env = env if env is not None else dict(os.environ) env = env if env is not None else dict(os.environ)
raw = env.get("AUDIT_CORE_SENDERS") raw = env.get("AUDIT_CORE_SENDERS")
if raw: if raw:
return cls(_parse_identities(raw)) return cls(_apply_scope_overlay(_parse_identities(raw), env))
legacy = (env.get("AUDIT_CORE_INGEST_TOKEN") or "").strip() legacy = (env.get("AUDIT_CORE_INGEST_TOKEN") or "").strip()
if not legacy: if not legacy:
@ -134,6 +135,66 @@ class SenderRegistry:
]) ])
def _load_scope_overlay(env: dict[str, str]) -> list[dict[str, Any]]:
"""Non-secret sender policy. Tokens never come from here.
``AUDIT_CORE_SENDERS_SCOPE`` is inline JSON (tests).
``AUDIT_CORE_SENDERS_SCOPE_PATH`` is a file (production ConfigMap).
"""
inline = env.get("AUDIT_CORE_SENDERS_SCOPE")
if inline:
payload = json.loads(inline)
else:
path = env.get("AUDIT_CORE_SENDERS_SCOPE_PATH")
if not path:
return []
payload = json.loads(Path(path).read_text())
if not isinstance(payload, list):
raise ValueError("sender scope overlay must be a JSON list")
return [entry for entry in payload if isinstance(entry, dict) and entry.get("name")]
def _apply_scope_overlay(
identities: list[SenderIdentity], env: dict[str, str]
) -> list[SenderIdentity]:
"""Overlay non-secret fields from Git/ConfigMap onto Secret-backed tokens.
ExternalSecret refresh cannot shrink ``user-engine`` tenants below the
declared scope (AUDIT-WP-0006-T05). Tokens are never taken from the overlay.
"""
overlay = {entry["name"]: entry for entry in _load_scope_overlay(env)}
if not overlay:
return identities
merged: list[SenderIdentity] = []
for identity in identities:
extra = overlay.get(identity.name)
if extra is None:
merged.append(identity)
continue
tenants = extra.get("tenants")
sources = extra.get("sources")
merged.append(
SenderIdentity(
name=identity.name,
tokens=identity.tokens,
sources=(
frozenset(str(s) for s in sources) if sources else identity.sources
),
tenants=(
frozenset(str(t) for t in tenants) if tenants else identity.tenants
),
may_write=(
bool(extra["may_write"]) if "may_write" in extra else identity.may_write
),
may_read=(
bool(extra["may_read"]) if "may_read" in extra else identity.may_read
),
secret_policy=identity.secret_policy,
)
)
return merged
def _parse_identities(raw: str) -> list[SenderIdentity]: def _parse_identities(raw: str) -> list[SenderIdentity]:
try: try:
entries = json.loads(raw) entries = json.loads(raw)

View file

@ -0,0 +1,124 @@
{
"schema_version": "0.1",
"record_scope": "operational",
"canon": {
"model": "ITC-CAP",
"model_version": "0.4.0",
"canon_version": "0.6.0",
"status": "draft",
"catalog": "info-tech-canon/infospace/models/capability/capabilities.yaml",
"evidence_basis_catalog": "info-tech-canon/infospace/models/governance/evidence-basis.yaml"
},
"record_id": "capability-case:audit-core-operational:2026-08",
"created_at": "2026-08-16T00:00:00Z",
"subject": "Live audit-core receiver on railiance01, restated in canon terms",
"note": "Joins reuse-surface id capability.audit.event-retain to ITC-CAP operations.audit. Maturity attaches to this provision, not the abstract capability. data.backup is cited, not restated.",
"reuse_surface": {
"id": "capability.audit.event-retain",
"path": "registry/capabilities/capability.audit.event-retain.md"
},
"requires": [
{
"consumer": "audit-core.railiance01",
"capability": "operations.audit",
"profile": "administrative",
"minimum_maturity": "D4",
"requirement_note": "Production dependency for user-engine outbox delivery. D4 is the current ask; D5 would need measured integrity verification and actively controlled reliability."
},
{
"consumer": "audit-core.railiance01",
"capability": "data.archive",
"profile": "operational",
"requirement_note": "INTENT still wants unbounded WORM archive beyond the 30-day platform backup window. Unmet. Owner: audit-core to write a demand; resource-control to procure a different bucket/lifecycle than Barman. No provision is invented here."
}
],
"provisions": [
{
"provider": "audit-core.railiance01",
"capability": "operations.audit",
"profile": "administrative",
"environment": "production",
"maturity": "D4",
"implements": "HTTP POST /v1/events into append-only PostgreSQL on platform-pg, namespace audit-core, ClusterIP + default-deny",
"maturity_rationale": "Approved for production dependency since AUDIT-WP-0005. Not D5: tamper_evidence is false (trigger is not a proof), one replica, reliability is not actively controlled.",
"uses_provisions": [
{
"capability": "data.transactional",
"provider": "rapp-postgres/platform-pg database audit_core",
"relation": "may_use",
"note": "operations.audit does not declare depends_on data.transactional in the catalog. The live store is Postgres; this names which provision satisfies it."
},
{
"capability": "data.backup",
"provider": "rapp-postgres/platform-pg CNPG barmanObjectStore",
"relation": "may_use",
"note": "Cite resource-control/data/capability/platform-audit-storage.json. Do not restate that case. Recoverable window 30 days, provision D4 against a D5 requirement.",
"evidence_basis": "measured",
"observed_at": "2026-08-14"
},
{
"capability": "security.secrets",
"provider": "OpenBao / external-secrets on reef-railiance",
"relation": "may_use",
"note": "ClusterSecretStore openbao-audit-core and openbao-audit-core-database. Never class P."
}
],
"consumes": [
{
"class": "S",
"name": "retained events",
"quantity": {
"value": null,
"unit": "GB"
},
"period": "month",
"basis": "unknown",
"gap": "pg_total_relation_size of audit_core has not been recorded against this provision (owner: audit-core)"
},
{
"class": "H",
"name": "receiver operation",
"quantity": {
"value": null,
"unit": "hour"
},
"period": "month",
"supply": "internal",
"basis": "unknown",
"gap": "operator hours are not recorded (owner: audit-core; start a time record later)"
},
{
"class": "I",
"name": "intelligence",
"quantity": {
"value": null,
"unit": "token"
},
"period": "month",
"basis": "unknown",
"gap": "audit-core does not meter token consumption against this provision (owner: audit-core)"
}
],
"evidence": [
{
"hook": "audit_records",
"basis": "measured",
"value": "in-pod remote failure matrix 12 pass / 0 fail / 3 skip; live accept 202 before and after lease rotation",
"observed_at": "2026-08-13",
"ref": "evidence/failure-matrix-20260813T103908Z.json"
},
{
"hook": "integrity_verification",
"basis": "unknown",
"gap": "events_append_only rejects UPDATE/DELETE for the runtime role; that is not a proof a database owner did not drop the trigger. Hash-chain or external anchor is not implemented (owner: audit-core). Do not borrow the restore drill for this hook."
}
]
}
],
"open_items": [
"data.archive is required by INTENT and unprovided. A founder decision is needed before resource-control procures a WORM/object-lock destination distinct from the 30-day Barman bucket.",
"integrity_verification is unknown. Trigger enforcement is not tamper evidence.",
"Class S/H/I consumption is unknown on this provision.",
"Do not emit booked cost or a second usage stream for platform:audit-storage."
]
}

View file

@ -10,7 +10,8 @@ Deployment until:
1. The image digest is pinned (currently `sha256:41493cd5…` from commit `3a7d63e`). 1. The image digest is pinned (currently `sha256:41493cd5…` from commit `3a7d63e`).
2. Secrets `audit-core-database`, `audit-core-database-migrate`, and 2. Secrets `audit-core-database`, `audit-core-database-migrate`, and
`audit-core-senders` exist. `audit-core-senders` exist. ConfigMap `audit-core-senders-scope` is
applied (`deploy/senders-scope.yaml`) before the Deployment mounts it.
3. Job `audit-core-migrate` has completed. 3. Job `audit-core-migrate` has completed.
```bash ```bash

View file

@ -90,11 +90,11 @@ spec:
value: "0.0.0.0" value: "0.0.0.0"
- name: AUDIT_CORE_HTTP_PORT - name: AUDIT_CORE_HTTP_PORT
value: "8080" value: "8080"
# Refuses to start on anything but archive-class custody, so a # Refuses to start on anything but operational (durable Postgres)
# missing database URL fails loudly instead of silently downgrading # custody. ``archive`` remains an accepted alias for one mixed
# to the development store. # rollout so an old manifest cannot refuse a new image.
- name: AUDIT_CORE_REQUIRE_CUSTODY_CLASS - name: AUDIT_CORE_REQUIRE_CUSTODY_CLASS
value: archive value: operational
# Runtime role cannot CREATE TABLE. Schema changes are a Job # Runtime role cannot CREATE TABLE. Schema changes are a Job
# with the migration lease (deploy/migrate-job.yaml). # with the migration lease (deploy/migrate-job.yaml).
- name: AUDIT_CORE_AUTO_MIGRATE - name: AUDIT_CORE_AUTO_MIGRATE
@ -122,6 +122,11 @@ spec:
secretKeyRef: secretKeyRef:
name: audit-core-senders name: audit-core-senders
key: senders.json key: senders.json
# Non-secret tenant/source scope. Tokens stay in the Secret;
# ExternalSecret refresh cannot shrink user-engine tenants
# below deploy/senders-scope.json.
- name: AUDIT_CORE_SENDERS_SCOPE_PATH
value: /etc/audit-core/senders-scope.json
resources: resources:
requests: requests:
cpu: 50m cpu: 50m
@ -144,6 +149,10 @@ spec:
- name: database-credential - name: database-credential
mountPath: /etc/audit-core/db mountPath: /etc/audit-core/db
readOnly: true readOnly: true
- name: senders-scope
mountPath: /etc/audit-core/senders-scope.json
subPath: senders-scope.json
readOnly: true
startupProbe: startupProbe:
httpGet: {path: /healthz, port: http} httpGet: {path: /healthz, port: http}
periodSeconds: 3 periodSeconds: 3
@ -175,3 +184,7 @@ spec:
secretName: audit-core-database secretName: audit-core-database
# 0440 + fsGroup 10001: 0400 is root-only and the process cannot read it. # 0440 + fsGroup 10001: 0400 is root-only and the process cannot read it.
defaultMode: 0440 defaultMode: 0440
- name: senders-scope
configMap:
name: audit-core-senders-scope
defaultMode: 0444

View file

@ -0,0 +1,9 @@
[
{
"name": "user-engine",
"sources": ["user-engine"],
"tenants": ["*"],
"may_write": true,
"may_read": false
}
]

23
deploy/senders-scope.yaml Normal file
View file

@ -0,0 +1,23 @@
# Non-secret sender scope (AUDIT-WP-0006-T05). Tokens stay in Secret
# audit-core-senders. This ConfigMap is the authority for tenants/sources
# so an ExternalSecret refresh cannot revert user-engine to a single tenant.
# Keep in lockstep with deploy/senders-scope.json.
---
apiVersion: v1
kind: ConfigMap
metadata:
name: audit-core-senders-scope
namespace: audit-core
labels:
app.kubernetes.io/name: audit-core
data:
senders-scope.json: |
[
{
"name": "user-engine",
"sources": ["user-engine"],
"tenants": ["*"],
"may_write": true,
"may_read": false
}
]

View file

@ -117,20 +117,26 @@ Compatibility rules (not yet implemented):
| Field | Meaning | | Field | Meaning |
| --- | --- | | --- | --- |
| `custody_class` | `development`, `archive`, or `hot_search` | | `custody_class` | `development`, `operational`, `archive`, or `hot_search` |
| `retention_days` | Maximum age before eligible deletion; `None` means indefinite | | `retention_days` | Maximum age before eligible deletion; `None` is a lifecycle statement (no expiry), not a recovery guarantee |
| `immutable` | Whether stored records are protected from in-place alteration | | `immutable` | Whether stored records are protected from in-place alteration |
| `tamper_evidence` | Whether manifests, hash chains, or signatures exist | | `tamper_evidence` | Whether manifests, hash chains, or signatures exist |
| `durable` | Whether survival is expected across process restarts and host reboots | | `durable` | Whether survival is expected across process restarts and host reboots |
| `recoverable_days` | Cited platform backup window; `None` if not declared |
| `recoverable_source` | Where the recoverable window is cited from |
| `recoverable_basis` | ITC-GOV EvidenceBasis of that citation (`measured`, `quoted`, …) |
### Custody classes ### Custody classes
| Class | Purpose | Guarantees | | Class | Purpose | Guarantees |
| --- | --- | --- | | --- | --- | --- |
| `development` | Local integration and bootstrap wiring | Ephemeral local files; best-effort cleanup; **not audit custody** | | `development` | Local integration and bootstrap wiring | Ephemeral local files; best-effort cleanup; **not audit custody** |
| `archive` | Long-term evidence (planned) | Durable object storage, batch manifests, explicit retention | | `operational` | Durable production custody | Append-only Postgres; recoverable through the platform `data.backup` provision; not ITC-CAP `data.archive` |
| `archive` | Long-term evidence (future sink) | Reserved for a backend that can satisfy `data.archive` hooks (retention policy, integrity verification, retrieval test) |
| `hot_search` | Operational investigation (planned) | Shorter retention; searchable; not the evidence record | | `hot_search` | Operational investigation (planned) | Shorter retention; searchable; not the evidence record |
`AUDIT_CORE_REQUIRE_CUSTODY_CLASS=operational` is the production fail-closed gate. `archive` is accepted as an alias of `operational` for one mixed rollout so an old manifest cannot refuse a new image. A `development` backend satisfies neither.
### Mock file backend policy ### Mock file backend policy
`MockFileAuditBackend.retention_policy`: `MockFileAuditBackend.retention_policy`:
@ -152,15 +158,20 @@ Enforcement:
**Not guaranteed:** crash-safe writes, replication, encryption, tenant isolation, **Not guaranteed:** crash-safe writes, replication, encryption, tenant isolation,
integrity proofs, or survival of `/tmp` across reboots. integrity proofs, or survival of `/tmp` across reboots.
### Production archive policy (planned) ### Production operational policy (Postgres, live)
Target guarantees for the first durable backend: `PostgresAuditBackend.retention_policy`:
- `custody_class`: `archive` - `custody_class`: `operational`
- `retention_days`: scope policy (often years, sometimes indefinite) - `retention_days`: unset in production (the service does not expire rows)
- `immutable`: true (WORM / object lock where available) - `immutable`: true (trigger `events_append_only`; not a claim against the database owner)
- `tamper_evidence`: true (batch manifests with content hashes) - `tamper_evidence`: false (a superuser can drop the trigger; no hash-chain)
- `durable`: true - `durable`: true
- `recoverable_days`: 30, cited from the platform `data.backup` provision
- `recoverable_source`: `resource-control/data/capability/platform-audit-storage.json#provisions[capability=data.backup]`
- `recoverable_basis`: `measured`
`None` retention is a lifecycle statement, not unbounded archive. Rows older than the recoverable window are not promised after a restore. A future `archive` backend that satisfies ITC-CAP `data.archive` is not implemented.
## Migration path: mock file → durable backend ## Migration path: mock file → durable backend
@ -189,15 +200,15 @@ backend.emit(AuditEvent(source="...", action="...", resource="...", outcome="suc
1. Register a durable archive backend implementing `AuditBackend`. 1. Register a durable archive backend implementing `AuditBackend`.
2. Configure routing: development scopes may keep mock; production scopes require 2. Configure routing: development scopes may keep mock; production scopes require
`custody_class=archive`. `custody_class=operational` (the live Postgres backend).
3. Readiness checks compare `backend.retention_policy` against scope policy and 3. Readiness checks compare `backend.retention_policy` against scope policy and
fail closed when custody is insufficient. fail closed when custody is insufficient.
### Phase 2 — archive primary (planned) ### Phase 2 — operational primary (live)
1. Point `emit` calls (or HTTP ingestion) at the archive backend. 1. HTTP ingestion writes through `PostgresAuditBackend` (`custody_class=operational`).
2. Retain mock only for local `make mock-audit-smoke` and unit tests. 2. Retain mock only for local `make mock-audit-smoke` and unit tests.
3. Export historical mock JSONL into archive batches with manifest generation. 3. A future `data.archive` sink is a separate backend, not a rename of Postgres.
### Phase 3 — hot search adjunct (planned) ### Phase 3 — hot search adjunct (planned)
@ -220,7 +231,9 @@ Archive remains the evidence record; hot search may use shorter `retention_days`
| Backend | Module | Custody class | | Backend | Module | Custody class |
| --- | --- | --- | | --- | --- | --- |
| Mock file JSONL | `audit_core.mock_file_backend.MockFileAuditBackend` | `development` | | Mock file JSONL | `audit_core.mock_file_backend.MockFileAuditBackend` | `development` |
| Archive (planned) | TBD | `archive` | | SQLite (local / test) | `audit_core.sqlite_backend.SQLiteAuditBackend` | `development` |
| PostgreSQL (production) | `audit_core.postgres_backend.PostgresAuditBackend` | `operational` |
| Archive (planned `data.archive` sink) | TBD | `archive` |
| Hot search (planned) | TBD | `hot_search` | | Hot search (planned) | TBD | `hot_search` |
## Related documents ## Related documents

View file

@ -27,10 +27,11 @@
"platform_logical_seconds": 3.468, "platform_logical_seconds": 3.468,
"retention": { "retention": {
"audit_core_retention_days": null, "audit_core_retention_days": null,
"audit_core_meaning": "does not expire or delete events", "audit_core_meaning": "does not expire or delete events; lifecycle statement, not a recovery guarantee",
"platform_planned_window_days": 30, "platform_planned_window_days": 30,
"production_barman": "fail-closed; no off-host copy yet", "production_barman": "superseded 2026-08-14 by RESOURCE-WP-0002-T05: live Scaleway Barman, 30-day window, audit_core.events 30=30, full 65s / PITR 65s",
"recovery_bound": "platform backup retention, not audit-core deletion" "recovery_bound": "platform data.backup window (resource:platform:audit-storage), not audit-core deletion",
"provision_honesty": "requirement D5, provision D4; cited, not re-scored here"
}, },
"verified": true "verified": true
} }

99
docs/interface-card.yaml Normal file
View file

@ -0,0 +1,99 @@
schema: info-tech-canon.interface-card.v1
id: audit-core/interface-card
title: audit-core Canon Interface Card
consumer: audit-core
consumer_profile:
repo: audit-core
domain: infotech
owner: audit-core
intent: >
Provide durable, tenant-aware operational custody for audit events so
senders can treat a 202 as evidence-in-store, not a log-forwarding hint.
scope:
- audit event ingestion
- append-only operational custody
- sender binding
- recovery bound to platform backup
purposes:
- id: audit-core/operational-custody
use_case: Accept normalized events from registered senders and retain them in an append-only store.
consumer_need: A joinable operations.audit provision with an honest recovery claim.
demand_signals:
- user-engine delivers platform and tenant events over POST /v1/events
- neighbours already require data.backup in ITC-CAP terms
canon_surfaces:
- model/capability
- model/governance
- model/data
- model/security
surfaces:
implemented_profiles: []
consumed_artifacts:
- model/capability
- model/governance
- model/data
- model/security
owned_concepts: []
produced_concepts:
- Evidence
- AuditRecord
consumed_concepts:
- Evidence
- EvidenceBasis
- CapabilityProvision
- RetentionRuleReference
mappings:
- from: stored event
to: Evidence / AuditRecord
note: This service stores evidence. It is not an independent Audit-as-assessment.
- from: capability.audit.event-retain
to: operations.audit
note: data/capability/audit-core-operational.json
validation_expectations:
commands:
- PYTHONPATH=src python3 -m info_tech_canon capability-review /home/worsch/audit-core/data/capability/audit-core-operational.json
evidence_required:
- data/capability/audit-core-operational.json
- docs/operator-runbook.md Restore section
- docs/evidence/restore-walk-20260813T121200Z.json
known_gaps:
- id: data.archive-unprovided
owner: audit-core
disposition: unmet requirement recorded on the ITC-CAP case; do not build the sink in AUDIT-WP-0006
- id: tamper-evidence-false
owner: audit-core
disposition: integrity_verification hook is unknown; trigger is not a proof
- id: no-hash-chain
owner: audit-core
disposition: INTENT residual, not this workplan
- id: single-sender
owner: audit-core
disposition: NetworkPolicy admits user-engine only; other sources remain adapters
- id: no-rapp-yaml
owner: railiance-master
disposition: schema requires rapp-*; extraction is a first-wave family decision
- id: historic-archive-overclaim
owner: audit-core
disposition: closed by AUDIT-WP-0006-T01; /readyz reports operational
purpose_fit:
state: partial
matched_capabilities:
- operations.audit
scope_pressure: >
INTENT describes a control plane, object archive, hot search, and export.
The live service is a single-sender operational custody receiver.
recommended_disposition: keep the operational provision honest; do not inflate to data.archive
consumer_needs:
current:
- Honest custody_class and recoverable window on /readyz
- Joinable operations.audit provision
requested_extensions:
- data.archive sink if events must survive past the 30-day backup window
feedback: []
known_deviations:
- no data.archive sink
- tamper_evidence=False
- no hash-chain
- single sender user-engine
- no rapp.yaml (not a rapp-* repo)
- /readyz historically overclaimed archive (closed by T01)

View file

@ -29,12 +29,13 @@ warden route show database-dynamic-credentials --json
| Check | Meaning | | Check | Meaning |
| --- | --- | | --- | --- |
| `GET /healthz` | Process is up. Liveness uses this. A database outage must **not** restart the pod. | | `GET /healthz` | Process is up. Liveness uses this. A database outage must **not** restart the pod. |
| `GET /readyz` | Custody is reachable and `custody_class=archive`. Readiness uses this; the pod leaves the Service rather than accept events it cannot store. | | `GET /readyz` | Custody is reachable and `custody_class=operational`. Also reports `recoverable_days` (cited platform backup window). Readiness uses this; the pod leaves the Service rather than accept events it cannot store. |
| `GET /v1/stats` | In-process counters since start (`accepted`, `duplicate`, `conflict`, `rejected`, `unauthorized`, `forbidden`, `unavailable`, `error`). Resets on restart. Requires `may_read`. | | `GET /v1/stats` | In-process counters since start (`accepted`, `duplicate`, `conflict`, `rejected`, `unauthorized`, `forbidden`, `unavailable`, `error`). Resets on restart. Requires `may_read`. |
A missing `AUDIT_CORE_DATABASE_URL` / credential directory is a startup A missing `AUDIT_CORE_DATABASE_URL` / credential directory is a startup
failure (`AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive`), not a silent downgrade failure (`AUDIT_CORE_REQUIRE_CUSTODY_CLASS=operational`), not a silent
to SQLite. downgrade to SQLite. An older manifest that still requires `archive` is
accepted as an alias for one mixed rollout.
## Lookup ## Lookup
@ -96,9 +97,14 @@ replace that path with a founder paste. Rotation is overlap-first:
5. Either wait for the 1h refresh or annotate the ExternalSecret to force a 5. Either wait for the 1h refresh or annotate the ExternalSecret to force a
sync, then restart the pod so it re-reads `AUDIT_CORE_SENDERS`. sync, then restart the pod so it re-reads `AUDIT_CORE_SENDERS`.
The write token is bound to `source=user-engine` and the tenants that The write token is bound to `source=user-engine`. Tenant scope for that
identity may claim. The operator token is a separate identity with identity is **not a secret**: `deploy/senders-scope.json` (ConfigMap
`may_read: true`. Do not reuse one token for both. `audit-core-senders-scope`) overlays `tenants: ["*"]` onto the Secret
at start. An ExternalSecret refresh cannot revert it to a single tenant.
Tokens stay in Secret `audit-core-senders` / OpenBao KV.
The operator token is a separate identity with `may_read: true`. Do not
reuse one token for both.
A shape (values are placeholders) is in `docs/senders.example.json`. A shape (values are placeholders) is in `docs/senders.example.json`.
@ -142,17 +148,34 @@ behind the read privilege). Watch:
## Restore ## Restore
audit-core does **not** expire events (`retention_days` unset). Recovery is audit-core does **not** expire events (`retention_days` unset). That is a
bounded by what rapp-postgres can restore, not by an audit-core deletion lifecycle policy statement, not a recovery guarantee. Recoverable history
window. The platform's planned Barman window is 30 days. Production Barman is the platform `data.backup` window: **30 days**, prefix `platform-pg/`,
is still fail-closed (no governed off-host target). Do not promise an RPO bucket owned by `resource:platform:audit-storage`. Cite, do not copy:
until that target exists. Local WAL on the node is not an off-host copy.
- `resource-control/data/capability/platform-audit-storage.json`
- `rapp-postgres/docs/restore.md`
The platform requirement is `data.backup` profile `database` at D5
(RPO 5 min, RTO 60 min, 30-day retention, not in the railiance01 /
host-europe failure domain). The live provision is **D4**, not D5:
resource-control scored one backup, one full restore, one PITR, and
does not yet emit `wal_archive_gap_minutes`. RPO/RTO numbers are
theirs (`measured`, single observation). audit-core does not claim a
better grade.
Rows older than the 30-day window are not promised after a restore.
Local WAL on the node is not a second copy.
Physical restore is instance-wide. A consumer-only restore is a logical Physical restore is instance-wide. A consumer-only restore is a logical
export of `audit_core` from a scratch physical restore, then a controlled export of `audit_core` from a scratch physical restore, then a controlled
import. Never recover in place. Procedure: `rapp-postgres/docs/restore.md`. import. Never recover in place. Procedure: `rapp-postgres/docs/restore.md`.
Walked 2026-08-13: The 2026-08-13 fail-closed sentence is superseded by RESOURCE-WP-0002-T05
(2026-08-14): production Barman to Scaleway, `audit_core.events` 30=30,
full restore 65 s, PITR 65 s.
Walked 2026-08-13 (pre-commissioning historical evidence):
| Path | Evidence | Elapsed | Result | | Path | Evidence | Elapsed | Result |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@ -185,5 +208,6 @@ narrows a column must replace that note before release.
3. Sender registry is Secret `audit-core-senders` (in-cluster mint). 3. Sender registry is Secret `audit-core-senders` (in-cluster mint).
Database leases come from `openbao-audit-core-database`. Database leases come from `openbao-audit-core-database`.
4. Job `audit-core-migrate` with `AUDIT_CORE_MIGRATE_ROLE=audit_core_migrate`. 4. Job `audit-core-migrate` with `AUDIT_CORE_MIGRATE_ROLE=audit_core_migrate`.
5. Deployment. `/readyz` must report `custody_class=archive`. 5. Deployment. `/readyz` must report `custody_class=operational` and
`recoverable_days=30`.
6. In-pod `MODE=remote DISRUPT=0` failure matrix. Evidence goes to NK-WP-0024. 6. In-pod `MODE=remote DISRUPT=0` failure matrix. Evidence goes to NK-WP-0024.

View file

@ -3,40 +3,37 @@ id: capability.audit.event-retain
name: Audit Event Retention name: Audit Event Retention
summary: Collect, normalize, retain, and search audit events with integrity evidence across tenants. summary: Collect, normalize, retain, and search audit events with integrity evidence across tenants.
owner: audit-core owner: audit-core
status: draft status: production
domain: helix_forge domain: infotech
tags: [audit, retention, compliance] tags: [audit, retention, compliance]
joins:
maturity: itc_cap: operations.audit
discovery: provision: data/capability/audit-core-operational.json
current: D4 provision_maturity: D4
target: D6
confidence: medium
rationale: audit-core INTENT defines full audit fabric scope and integration boundaries.
availability:
current: A2
target: A5
confidence: low
rationale: Core modules exist; deployable service packaging in progress.
external_evidence: external_evidence:
completeness: completeness:
level: C2 level: C3
name: Partial name: Substantial
confidence: low confidence: medium
basis: scope_vs_intent_and_consumer_expectations basis: live_receiver_and_restore_walk
satisfied_expectations: satisfied_expectations:
- retention and integrity goals documented - HTTP ingest through the backend contract
- append-only Postgres custody on platform-pg
- recovery cited to the live platform data.backup provision
broken_expectations: broken_expectations:
- federation with all platform runtimes not proven in registry - data.archive sink not provided
- tamper evidence not implemented
out_of_scope_expectations: out_of_scope_expectations:
- application business audit semantics ownership - application business audit semantics ownership
- booked-cost origination
reliability: reliability:
level: R1 level: R2
confidence: low confidence: medium
basis: consumer_quality_signals basis: failure_matrix_and_restore_walk
known_reliability_risks: known_reliability_risks:
- multi-tenant isolation not evidenced here - single replica
- integrity_verification hook unmet
discovery: discovery:
intent: > intent: >
@ -49,14 +46,19 @@ discovery:
- tamper evidence - tamper evidence
excludes: excludes:
- generating domain business events - generating domain business events
- procuring or operating platform backup
- booked financial facts
use_cases: [] use_cases: []
availability: availability:
current_level: A2 current_level: A4
target_level: A5 target_level: A5
current_artifacts: current_artifacts:
- audit-core/ - audit-core/deploy/audit-core.yaml
- audit-core/audit_core/postgres_backend.py
- rapp-postgres/consumers/audit-core.yaml
consumption_modes: consumption_modes:
- http ingest
- source module - source module
relations: relations:
@ -64,17 +66,25 @@ relations:
related_to: related_to:
- capability.activity.event-coordinate - capability.activity.event-coordinate
- capability.statehub.progress-log - capability.statehub.progress-log
uses_provisions:
- data.transactional (rapp-postgres/platform-pg)
- data.backup (resource:platform:audit-storage, cited)
- security.secrets (OpenBao / ESO)
consumer_guidance: consumer_guidance:
recommended_for: recommended_for:
- planning audit retention independent of a single product - platform and application audit event delivery over POST /v1/events
not_recommended_for: not_recommended_for:
- treating this store as WORM archive
- replacing application-level logging only - replacing application-level logging only
known_limitations: known_limitations:
- consumer evidence not yet collected in registry - recoverable history is the 30-day platform backup window
- no hash-chain or export API yet
--- ---
# Audit Event Retention # Audit Event Retention
Audit Core provides the retention and integrity layer for audit events across Audit Core provides the operational custody layer for audit events.
the platform. ITC-CAP join: `operations.audit` at provision maturity D4
(`data/capability/audit-core-operational.json`). Maturity is not a
property of this abstract capability.

View file

@ -1,14 +1,16 @@
version: 1 version: 1
updated: '2026-06-16' updated: '2026-08-16'
domain: helix_forge domain: infotech
capabilities: capabilities:
- id: capability.audit.event-retain - id: capability.audit.event-retain
name: Audit Event Retention name: Audit Event Retention
summary: Collect, normalize, retain, and search audit events with integrity evidence summary: Collect, normalize, retain, and search audit events with integrity evidence
across tenants. across tenants.
vector: D4 / A2 / C2 / R1 joins: operations.audit
domain: helix_forge provision: data/capability/audit-core-operational.json
status: draft vector: D4 provision / A4 / C3 / R2
domain: infotech
status: production
owner: audit-core owner: audit-core
path: registry/capabilities/capability.audit.event-retain.md path: registry/capabilities/capability.audit.event-retain.md
tags: tags:
@ -16,4 +18,5 @@ capabilities:
- retention - retention
- compliance - compliance
consumption_modes: consumption_modes:
- http ingest
- source module - source module

View file

@ -184,7 +184,7 @@ def start_stack() -> subprocess.Popen:
"AUDIT_CORE_SENDERS": senders, "AUDIT_CORE_SENDERS": senders,
"AUDIT_CORE_HOST": "127.0.0.1", "AUDIT_CORE_HOST": "127.0.0.1",
"AUDIT_CORE_HTTP_PORT": str(APP_PORT), "AUDIT_CORE_HTTP_PORT": str(APP_PORT),
"AUDIT_CORE_REQUIRE_CUSTODY_CLASS": "archive", "AUDIT_CORE_REQUIRE_CUSTODY_CLASS": "operational",
"AUDIT_CORE_DB_STATEMENT_TIMEOUT_MS": "5000", "AUDIT_CORE_DB_STATEMENT_TIMEOUT_MS": "5000",
"AUDIT_CORE_LOG_LEVEL": "WARNING", "AUDIT_CORE_LOG_LEVEL": "WARNING",
} }
@ -391,7 +391,7 @@ def start_app_process() -> subprocess.Popen:
"AUDIT_CORE_SENDERS": senders, "AUDIT_CORE_SENDERS": senders,
"AUDIT_CORE_HOST": "127.0.0.1", "AUDIT_CORE_HOST": "127.0.0.1",
"AUDIT_CORE_HTTP_PORT": str(APP_PORT), "AUDIT_CORE_HTTP_PORT": str(APP_PORT),
"AUDIT_CORE_REQUIRE_CUSTODY_CLASS": "archive", "AUDIT_CORE_REQUIRE_CUSTODY_CLASS": "operational",
"AUDIT_CORE_DB_STATEMENT_TIMEOUT_MS": "5000", "AUDIT_CORE_DB_STATEMENT_TIMEOUT_MS": "5000",
"AUDIT_CORE_LOG_LEVEL": "WARNING", "AUDIT_CORE_LOG_LEVEL": "WARNING",
} }

View file

@ -86,7 +86,11 @@ def digest(event: AuditEvent) -> str:
def test_declares_a_retention_policy(backend): def test_declares_a_retention_policy(backend):
policy = backend.retention_policy policy = backend.retention_policy
assert policy.durable is True assert policy.durable is True
assert policy.custody_class in ("development", "archive", "hot_search") assert policy.custody_class in ("development", "operational", "archive", "hot_search")
if policy.custody_class == "operational":
assert policy.recoverable_days == 30
assert policy.recoverable_basis == "measured"
assert policy.recoverable_source
# A backend claiming tamper evidence must also claim immutability; # A backend claiming tamper evidence must also claim immutability;
# the reverse is allowed. # the reverse is allowed.
if policy.tamper_evidence: if policy.tamper_evidence:

View file

@ -0,0 +1,65 @@
import json
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
RECORD = ROOT / "data" / "capability" / "audit-core-operational.json"
CARD = ROOT / "docs" / "interface-card.yaml"
SCHEMA = Path.home() / "info-tech-canon" / "infospace" / "schemas" / "interface-card.schema.yaml"
def test_capability_record_exists_and_joins_operations_audit():
record = json.loads(RECORD.read_text())
assert any(item["capability"] == "operations.audit" for item in record["requires"])
assert any(item["capability"] == "data.archive" for item in record["requires"])
provisions = {item["capability"]: item for item in record["provisions"]}
assert "data.archive" not in provisions
audit = provisions["operations.audit"]
assert audit["maturity"] == "D4"
assert audit["provider"] == "audit-core.railiance01"
used = {item["capability"]: item["relation"] for item in audit["uses_provisions"]}
assert used["data.backup"] == "may_use"
assert used["security.secrets"] == "may_use"
unknown = [row for row in audit["consumes"] if row["basis"] == "unknown"]
assert unknown
assert all(row["quantity"]["value"] is None and row.get("gap") for row in unknown)
def test_capability_review_against_live_catalog():
import sys
canon_src = Path.home() / "info-tech-canon" / "src"
if canon_src.is_dir() and str(canon_src) not in sys.path:
sys.path.insert(0, str(canon_src))
try:
from info_tech_canon.capability import review_path
except ImportError:
pytest.skip("info-tech-canon is not importable")
result = review_path(RECORD)
assert result["ok"] is True
by_cap = {item["capability"]: item for item in result["requirements"]}
assert by_cap["operations.audit"]["status"] == "met"
assert by_cap["data.archive"]["status"] == "unprovided"
def test_interface_card_has_required_fields():
text = CARD.read_text()
assert "id: audit-core/interface-card" in text
assert "title:" in text
assert "consumer:" in text
assert "canon_surfaces:" in text
assert "Evidence" in text
assert "AuditRecord" in text
assert "data.archive-unprovided" in text
assert "no-rapp-yaml" in text
def test_interface_card_validates_against_canon_schema():
if not SCHEMA.is_file():
pytest.skip("info-tech-canon interface-card schema not on disk")
yaml = pytest.importorskip("yaml")
jsonschema = pytest.importorskip("jsonschema")
schema = yaml.safe_load(SCHEMA.read_text())
card = yaml.safe_load(CARD.read_text())
jsonschema.Draft202012Validator(schema).validate(card)

View file

@ -4,7 +4,11 @@ import json
import pytest import pytest
from audit_core.ingestion import IngestionApplication from audit_core.ingestion import IngestionApplication
from audit_core.interface import BackendUnavailableError, RetentionPolicy from audit_core.interface import (
BackendUnavailableError,
RetentionPolicy,
custody_class_satisfies,
)
from audit_core.mock_file_backend import MockFileAuditBackend from audit_core.mock_file_backend import MockFileAuditBackend
from audit_core.sqlite_backend import SQLiteAuditBackend from audit_core.sqlite_backend import SQLiteAuditBackend
@ -408,10 +412,71 @@ def test_required_custody_class_refuses_a_development_backend(tmp_path):
"""Losing AUDIT_CORE_DATABASE_URL must fail to start, not silently """Losing AUDIT_CORE_DATABASE_URL must fail to start, not silently
downgrade custody to the development store.""" downgrade custody to the development store."""
backend = SQLiteAuditBackend(str(tmp_path / "dev.db")) backend = SQLiteAuditBackend(str(tmp_path / "dev.db"))
with pytest.raises(ValueError, match="does not meet the required"):
IngestionApplication(backend, "opaque", require_custody_class="operational")
with pytest.raises(ValueError, match="does not meet the required"): with pytest.raises(ValueError, match="does not meet the required"):
IngestionApplication(backend, "opaque", require_custody_class="archive") IngestionApplication(backend, "opaque", require_custody_class="archive")
def test_operational_and_archive_alias_for_one_deploy():
"""A mixed rollout must start: new backend + old require, and the reverse."""
class _Operational(_BrokenBackend):
@property
def retention_policy(self):
return RetentionPolicy(
custody_class="operational",
retention_days=None,
immutable=True,
tamper_evidence=False,
durable=True,
recoverable_days=30,
recoverable_source="cited",
recoverable_basis="measured",
)
IngestionApplication(_Operational(), "opaque", require_custody_class="archive")
IngestionApplication(_Operational(), "opaque", require_custody_class="operational")
def test_custody_class_alias_is_not_development():
assert custody_class_satisfies("operational", "archive")
assert custody_class_satisfies("archive", "operational")
assert not custody_class_satisfies("development", "operational")
assert not custody_class_satisfies("development", "archive")
assert custody_class_satisfies("development", "development")
def test_readiness_reports_recovery_fields_for_operational_backend():
class _Operational(_BrokenBackend):
@property
def retention_policy(self):
return RetentionPolicy(
custody_class="operational",
retention_days=None,
immutable=True,
tamper_evidence=False,
durable=True,
recoverable_days=30,
recoverable_source="resource-control/data/capability/platform-audit-storage.json",
recoverable_basis="measured",
)
def health(self):
return None
status, body = invoke(
IngestionApplication(_Operational(), "opaque"),
None, path="/readyz", method="GET", body=b"",
)
assert status.startswith("200")
assert body["custody_class"] == "operational"
assert body["durable"] is True
assert body["recoverable_days"] == 30
assert body["recoverable_basis"] == "measured"
assert "platform-audit-storage" in body["recoverable_source"]
def test_counters_track_each_outcome(tmp_path): def test_counters_track_each_outcome(tmp_path):
app, _ = bound_app(tmp_path, may_read=True) app, _ = bound_app(tmp_path, may_read=True)
invoke(app, event()) # accepted invoke(app, event()) # accepted

View file

@ -8,6 +8,7 @@ from audit_core.interface import (
EventValidationError, EventValidationError,
RetentionPolicy, RetentionPolicy,
SCHEMA_VERSION_V1ALPHA1, SCHEMA_VERSION_V1ALPHA1,
custody_class_satisfies,
validate_event, validate_event,
) )
from audit_core.mock_file_backend import MockFileAuditBackend from audit_core.mock_file_backend import MockFileAuditBackend
@ -79,6 +80,29 @@ def test_mock_backend_retention_policy_none_when_cleanup_disabled():
assert backend.retention_policy.retention_days is None assert backend.retention_policy.retention_days is None
def test_custody_class_satisfies_aliases_operational_and_archive():
assert custody_class_satisfies("operational", "archive") is True
assert custody_class_satisfies("archive", "operational") is True
assert custody_class_satisfies("operational", "development") is False
def test_readiness_payload_includes_recovery_when_declared():
policy = RetentionPolicy(
custody_class="operational",
retention_days=None,
immutable=True,
tamper_evidence=False,
durable=True,
recoverable_days=30,
recoverable_source="cited",
recoverable_basis="measured",
)
body = policy.as_readiness()
assert body["status"] == "ok"
assert body["custody_class"] == "operational"
assert body["recoverable_days"] == 30
def test_audit_event_record_uses_v1alpha1_schema(): def test_audit_event_record_uses_v1alpha1_schema():
event = AuditEvent( event = AuditEvent(
source="audit-core", source="audit-core",

97
tests/test_senders.py Normal file
View file

@ -0,0 +1,97 @@
import json
from pathlib import Path
import pytest
from audit_core.senders import SenderRegistry, WILDCARD
ROOT = Path(__file__).resolve().parents[1]
SCOPE_FILE = ROOT / "deploy" / "senders-scope.json"
def test_declared_scope_keeps_user_engine_tenants_wildcard():
scope = json.loads(SCOPE_FILE.read_text())
user_engine = next(entry for entry in scope if entry["name"] == "user-engine")
assert user_engine["tenants"] == ["*"]
assert user_engine["sources"] == ["user-engine"]
assert user_engine["may_write"] is True
assert user_engine["may_read"] is False
assert "tokens" not in user_engine
assert "token" not in user_engine
def test_scope_overlay_widens_narrow_secret_tenants(tmp_path):
secret = json.dumps(
[
{
"name": "user-engine",
"tokens": ["live-token"],
"sources": ["user-engine"],
"tenants": ["tenant:friendly:binky"],
"may_write": True,
"may_read": False,
}
]
)
env = {
"AUDIT_CORE_SENDERS": secret,
"AUDIT_CORE_SENDERS_SCOPE_PATH": str(SCOPE_FILE),
}
registry = SenderRegistry.from_env(env)
identity = registry.authenticate("Bearer live-token")
assert identity is not None
assert WILDCARD in identity.tenants
assert identity.permits_tenant("tenant:other:x")
assert identity.tokens == ("live-token",)
def test_scope_overlay_does_not_take_tokens_from_git():
overlay = json.dumps(
[
{
"name": "user-engine",
"tokens": ["must-not-be-used"],
"tenants": ["*"],
}
]
)
secret = json.dumps(
[
{
"name": "user-engine",
"tokens": ["live-token"],
"sources": ["user-engine"],
"tenants": ["tenant:friendly:binky"],
}
]
)
registry = SenderRegistry.from_env(
{"AUDIT_CORE_SENDERS": secret, "AUDIT_CORE_SENDERS_SCOPE": overlay}
)
assert registry.authenticate("Bearer must-not-be-used") is None
assert registry.authenticate("Bearer live-token") is not None
def test_missing_overlay_leaves_secret_as_is():
secret = json.dumps(
[
{
"name": "user-engine",
"tokens": ["live-token"],
"sources": ["user-engine"],
"tenants": ["tenant:friendly:binky"],
}
]
)
identity = SenderRegistry.from_env({"AUDIT_CORE_SENDERS": secret}).identities[0]
assert identity.tenants == frozenset({"tenant:friendly:binky"})
def test_invalid_scope_overlay_is_a_startup_error():
secret = json.dumps(
[{"name": "user-engine", "tokens": ["t"], "sources": ["user-engine"]}]
)
with pytest.raises(ValueError, match="JSON list"):
SenderRegistry.from_env(
{"AUDIT_CORE_SENDERS": secret, "AUDIT_CORE_SENDERS_SCOPE": "{}"}
)

View file

@ -4,16 +4,17 @@ type: workplan
title: "Honest operational custody against ITC-CAP and the live platform backup" title: "Honest operational custody against ITC-CAP and the live platform backup"
domain: infotech domain: infotech
repo: audit-core repo: audit-core
status: ready status: finished
owner: grok owner: grok
topic_slug: railiance topic_slug: railiance
created: "2026-08-15" created: "2026-08-15"
updated: "2026-08-15" updated: "2026-08-16"
depends_on: depends_on:
- AUDIT-WP-0005 - AUDIT-WP-0005
- RESOURCE-WP-0002 - RESOURCE-WP-0002
- ITC-WP-0014 - ITC-WP-0014
- ITC-WP-0015 - ITC-WP-0015
state_hub_workstream_id: "8d775ffb-3c83-4c33-9ffa-05ce52c5ff91"
--- ---
# AUDIT-WP-0006 — Honest operational custody against ITC-CAP and the live platform backup # AUDIT-WP-0006 — Honest operational custody against ITC-CAP and the live platform backup
@ -122,8 +123,9 @@ not build the sink here.
```task ```task
id: AUDIT-WP-0006-T01 id: AUDIT-WP-0006-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "bbf476eb-7bc3-4cb6-bd1c-9b2c75903906"
``` ```
`CustodyClass` is currently `development | archive | hot_search`. The `CustodyClass` is currently `development | archive | hot_search`. The
@ -164,12 +166,21 @@ Done when: unit tests cover the new class and the alias; production
manifest requires the honest class; contract and `/readyz` no longer manifest requires the honest class; contract and `/readyz` no longer
call Postgres `data.archive`. call Postgres `data.archive`.
Done 2026-08-16: `CustodyClass` includes `operational`; Postgres reports
it with a 30-day cited recoverable window (`measured`).
`AUDIT_CORE_REQUIRE_CUSTODY_CLASS=operational` in the manifest; `archive`
is a one-deploy alias. `/readyz` publishes recovery fields. Contract
replaced "Production archive policy (planned)" with the live operational
policy. Suite 84 passed. **Do not apply the Deployment until a new image
is pinned** — the live image still reports `archive` and has no alias.
## T02 — Bind the recovery promise to the live platform backup ## T02 — Bind the recovery promise to the live platform backup
```task ```task
id: AUDIT-WP-0006-T02 id: AUDIT-WP-0006-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "06907b2c-a822-48f0-8edc-50dd8914342b"
``` ```
`docs/operator-runbook.md` still says production Barman is fail-closed `docs/operator-runbook.md` still says production Barman is fail-closed
@ -198,12 +209,17 @@ RESOURCE-WP-0002-T05 (30 events, 65 s full / 65 s PITR).
Done when the runbook and the T06 evidence note no longer contradict Done when the runbook and the T06 evidence note no longer contradict
the live backup provision. the live backup provision.
Done 2026-08-16: Restore section cites `resource:platform:audit-storage`,
D5 requirement / D4 provision, and supersedes the 2026-08-13 fail-closed
sentence with RESOURCE-WP-0002-T05. Evidence JSON updated in place.
## T03 — Publish an ITC-CAP case for the live provision ## T03 — Publish an ITC-CAP case for the live provision
```task ```task
id: AUDIT-WP-0006-T03 id: AUDIT-WP-0006-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "eba29df0-36b9-4f43-add6-760285c56bde"
``` ```
Neighbours already restate real provisions against the live catalog Neighbours already restate real provisions against the live catalog
@ -265,12 +281,18 @@ catalog. Gaps are allowed when they name owner and disposition.
Done when the record validates and the reuse-surface card no longer Done when the record validates and the reuse-surface card no longer
contradicts the live receiver. contradicts the live receiver.
Done 2026-08-16: `data/capability/audit-core-operational.json` reviews
`ok` against ITC-CAP 0.4.0 (`operations.audit` met at D4; `data.archive`
unprovided). Reuse-surface card joins that provision, domain `infotech`,
status `production`.
## T04 — Publish a Canon Interface Card ## T04 — Publish a Canon Interface Card
```task ```task
id: AUDIT-WP-0006-T04 id: AUDIT-WP-0006-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "c5f5a883-834f-42eb-8f05-de2857d3904f"
``` ```
ITC-GOV / ITC-SEC / ITC-DATA say subsystems that produce Evidence ITC-GOV / ITC-SEC / ITC-DATA say subsystems that produce Evidence
@ -297,12 +319,17 @@ Declare at least:
Done when the card exists in-repo and validates against Done when the card exists in-repo and validates against
`interface-card.schema.yaml`. `interface-card.schema.yaml`.
Done 2026-08-16: `docs/interface-card.yaml` validates. Schema wants
`consumer` as a string and `canon_surfaces` as a string array; richer
template fields live under `consumer_profile` / `surfaces`.
## T05 — Persist sender tenant scope and refresh SCOPE ## T05 — Persist sender tenant scope and refresh SCOPE
```task ```task
id: AUDIT-WP-0006-T05 id: AUDIT-WP-0006-T05
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "c629d894-2fe6-4b46-9909-7267d9170c84"
``` ```
Inbox 2026-08-13 from net-kingdom (NK-WP-0024): live user-engine sender Inbox 2026-08-13 from net-kingdom (NK-WP-0024): live user-engine sender
@ -327,6 +354,12 @@ Done when the live sender document cannot revert to a single-tenant
list on refresh, SCOPE matches the repo, and the net-kingdom message list on refresh, SCOPE matches the repo, and the net-kingdom message
is answered. is answered.
Done 2026-08-16: Live Secret already has user-engine `tenants: ["*"]`.
`deploy/senders-scope.json` + ConfigMap `audit-core-senders-scope`
applied on railiance01. The process overlays that file over the Secret
so a later KV refresh cannot shrink tenants. Tokens stay out of Git.
SCOPE current state updated.
## Acceptance ## Acceptance
- Production fail-closed gate no longer keys off the word `archive`. - Production fail-closed gate no longer keys off the word `archive`.