diff --git a/approval_engine/audit.py b/approval_engine/audit.py index 31cdb6d..5291d2d 100644 --- a/approval_engine/audit.py +++ b/approval_engine/audit.py @@ -9,6 +9,7 @@ from typing import Any, Callable from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +from .coordination import FRAME_SECONDS from .store import Engine @@ -91,7 +92,7 @@ class OutboxWorker: engine: Engine, sink: Callable[[dict[str, Any]], None], *, - heartbeat_interval_seconds: int = 300, + heartbeat_interval_seconds: int = FRAME_SECONDS, ) -> None: self.engine = engine self.sink = sink diff --git a/approval_engine/cli.py b/approval_engine/cli.py index 05802af..d39381a 100644 --- a/approval_engine/cli.py +++ b/approval_engine/cli.py @@ -8,6 +8,7 @@ from wsgiref.simple_server import make_server from .api import App from .audit import AuditCoreSink, OutboxWorker +from .coordination import FRAME_SECONDS from .auth import Identity, JWTAuthenticator, StaticTokenAuthenticator from .store import Engine @@ -45,7 +46,7 @@ def _parser() -> argparse.ArgumentParser: serve.add_argument("--audit-url") serve.add_argument("--audit-token-file") serve.add_argument("--outbox-poll-seconds", type=float, default=5) - serve.add_argument("--heartbeat-seconds", type=int, default=86400) + serve.add_argument("--heartbeat-seconds", type=int, default=FRAME_SECONDS) migrate = sub.add_parser("migrate", help="apply repeatable schema migrations") _common_db(migrate) verify = sub.add_parser("verify", help="verify schema and SQLite integrity") diff --git a/approval_engine/coordination.py b/approval_engine/coordination.py new file mode 100644 index 0000000..875e896 --- /dev/null +++ b/approval_engine/coordination.py @@ -0,0 +1,6 @@ +"""Generated cadence timings; regenerate with NetKingdom coordination-frame tool.""" +# Source: coordination-engine/config/coordination-frame.json +FRAME_CONFIG_SHA256 = "d15e092387f128ffdfd00b9815707a05ff77c5bb0ba4d9a428fbc37b8c440b67" +FRAME_SECONDS = 3600 +RECEIVER_WAIT_SECONDS = 7200 +EFFECTIVE_DEADLINE_SECONDS = 10800 diff --git a/cadence.yaml b/cadence.yaml index 2de79a5..e091fc6 100644 --- a/cadence.yaml +++ b/cadence.yaml @@ -25,14 +25,15 @@ }, "heartbeat": { "event_class": "audit-core.heartbeat", - "interval_seconds": 86400, + "interval_seconds": 3600, "assertion": "nothing-to-report", "missing": "finding", "extensions": { "approval-engine": { "asserted_class": "issuance", - "requested_observer_max_gap_seconds": 172800, - "observer_registration_status": "pending" + "requested_observer_max_gap_seconds": 7200, + "observer_registration_status": "pending", + "effective_deadline_seconds": 10800 } } } @@ -59,14 +60,15 @@ }, "heartbeat": { "event_class": "audit-core.heartbeat", - "interval_seconds": 86400, + "interval_seconds": 3600, "assertion": "nothing-to-report", "missing": "finding", "extensions": { "approval-engine": { "asserted_class": "use", - "requested_observer_max_gap_seconds": 172800, - "observer_registration_status": "pending" + "requested_observer_max_gap_seconds": 7200, + "observer_registration_status": "pending", + "effective_deadline_seconds": 10800 } } } @@ -93,14 +95,15 @@ }, "heartbeat": { "event_class": "audit-core.heartbeat", - "interval_seconds": 86400, + "interval_seconds": 3600, "assertion": "nothing-to-report", "missing": "finding", "extensions": { "approval-engine": { "asserted_class": "supersession", - "requested_observer_max_gap_seconds": 172800, - "observer_registration_status": "pending" + "requested_observer_max_gap_seconds": 7200, + "observer_registration_status": "pending", + "effective_deadline_seconds": 10800 } } } @@ -127,14 +130,15 @@ }, "heartbeat": { "event_class": "audit-core.heartbeat", - "interval_seconds": 86400, + "interval_seconds": 3600, "assertion": "nothing-to-report", "missing": "finding", "extensions": { "approval-engine": { "asserted_class": "revocation", - "requested_observer_max_gap_seconds": 172800, - "observer_registration_status": "pending" + "requested_observer_max_gap_seconds": 7200, + "observer_registration_status": "pending", + "effective_deadline_seconds": 10800 } } } @@ -191,6 +195,16 @@ "status": "candidate", "schema_sha256": "6d52692eb1e4d1325e0d6062d95acded98beddaae5bcfe24c6f0e91f1b6be6ae" } + }, + "netkingdom": { + "coordination_frame": { + "ref": "coordination-engine/config/coordination-frame.json", + "sha256": "d15e092387f128ffdfd00b9815707a05ff77c5bb0ba4d9a428fbc37b8c440b67", + "frame_seconds": 3600, + "heartbeat_frames": 1, + "registered_wait_frames": 2, + "effective_deadline_frames": 3 + } } } } diff --git a/deploy/approval-engine.yaml b/deploy/approval-engine.yaml index 27d5913..927020b 100644 --- a/deploy/approval-engine.yaml +++ b/deploy/approval-engine.yaml @@ -32,6 +32,8 @@ spec: app.kubernetes.io/name: approval-engine template: metadata: + annotations: + coordination.coulomb.social/frame-sha256: "d15e092387f128ffdfd00b9815707a05ff77c5bb0ba4d9a428fbc37b8c440b67" labels: app.kubernetes.io/name: approval-engine spec: @@ -72,6 +74,8 @@ spec: - approval-engine - --jwks-url - http://keycape.sso.svc.cluster.local:8080/jwks + - --heartbeat-seconds + - "3600" - --audit-url - http://audit-core.audit-core.svc.cluster.local:8080 - --audit-token-file diff --git a/docs/audit-source-registration.md b/docs/audit-source-registration.md index d9c76ab..95773cf 100644 --- a/docs/audit-source-registration.md +++ b/docs/audit-source-registration.md @@ -35,15 +35,18 @@ whichever class happens to be busy and says nothing about the quiet one — here that is `revocation`, whose silence is the only silence that matters. Requested `heartbeat_classes`, all four at the same bound: -| Class | Longest gap that is not yet a finding | +| Class | Registered wait before receiver grace | | --- | --- | -| `issuance` | 172800s (48h) | -| `use` | 172800s (48h) | -| `supersession` | 172800s (48h) | -| `revocation` | 172800s (48h) | +| `issuance` | 2f = 7200s (2h) | +| `use` | 2f = 7200s (2h) | +| `supersession` | 2f = 7200s (2h) | +| `revocation` | 2f = 7200s (2h) | -Twice the deployed `--heartbeat-seconds` default of 86400, so one missed cycle -is not a finding and two consecutive ones are. The engine emits all four in a +These are registered base waits, not the effective alert threshold. Founder +coordination-frame decision 2026-10-15 sets f=3600s. The prepared emitter +interval is f; the receiver wait is 2f=7200s, and Audit Core's existing 1.5 +grace gives an effective deadline of 3f=10800s. This source request is not a +live registry receipt or proof that deployment has changed pace. The engine emits all four in a single transaction: a partial emission would report some classes healthy and others stalled, which is a worse signal than none. @@ -83,3 +86,12 @@ owners and bounds; this registration does not claim their completion. First provisioning still requires the platform-owned custody record linking the receiver's protected sender registry and this mounted credential. No credential value belongs in this document, Git, Hub or a rollout receipt. + + +Current timing values derive from coordination-engine/config/coordination-frame.json +through net-kingdom/tools/coordination-frame/render.py. The generated declaration, +consumer constants and deployment pod annotation carry its SHA-256. Existing +credential scope and the native September 15 bearer-revocation proof are +retained; do not repeat that unchanged proof as if it were missing. Actual +lifecycle reconciliation, class registration, lag findings and operational +observer readback remain APPROVAL-WP-0002-T04. diff --git a/docs/emission-cadence.md b/docs/emission-cadence.md index 879cc3f..8db48fc 100644 --- a/docs/emission-cadence.md +++ b/docs/emission-cadence.md @@ -21,10 +21,10 @@ becomes a missing positive rather than a quiet month. | Field | Value | | --- | --- | | Class | `heartbeat` | -| Interval | 24 hours (SHOULD also be emittable on demand) | +| Interval | f = 1 hour; SHOULD also be emittable on demand | | Assertion | `nothing-to-report` | | Counts | `{issuance, use, supersession, revocation}` of committed outbox rows | -| Missing heartbeat | **finding** after the requested 172800s receiver bound; registration pending | +| Missing heartbeat | **finding** after effective 3f = 3h (registration 2f = 2h plus receiver 1.5 grace); admission pending | ### Reconciliation @@ -48,7 +48,7 @@ case §9.6 names. ## What is a finding -- No per-class heartbeat arrives beyond the requested 48h receiver bound (24h emission interval). +- No per-class heartbeat arrives beyond the effective 3f deadline (f emission interval). - `audit-core` count for a class is less than this engine's drained count for that class. - Outbox depth or age exceeds the lag bound in `cadence.yaml` (drain stuck). @@ -143,3 +143,19 @@ snapshot proves completeness or resistance to source compromise. This implements T04's missing bounded source computation. Receiver registration, observed lag/findings and native reconciliation remain blocked owner acceptance; no existing live image is promoted by this change. + + +## Configurable coordination frame — 2026-10-15 + +The timing paragraphs in the dated October 3 migration section describe its +historical daily defaults. Current source defaults and the prepared manifest +are derived from coordination-engine/config/coordination-frame.json, initially +f=3600s by founder decision. The generated approval_engine/coordination.py and +cadence.yaml retain the config digest; CLI and direct OutboxWorker defaults +use FRAME_SECONDS. The deployment argument explicitly supplies the same value +and its pod annotation retains the frame digest. Runtime applies remain under +the owner change gate; no hourly live cadence is claimed by this source edit. + +Class mappings, ownership, atomic outbox behavior and count-window accounting +are unchanged. A different frame is regenerated for emitter and observer +settings together using net-kingdom/tools/coordination-frame/render.py. diff --git a/docs/production-dependencies.md b/docs/production-dependencies.md index 16f065e..fb28d28 100644 --- a/docs/production-dependencies.md +++ b/docs/production-dependencies.md @@ -6,7 +6,7 @@ the workplan remains `blocked` after completing reachable source work. | Task | Owner return required | Evidence that clears the wait | | --- | --- | --- | | T01 — authenticated approver evidence | key-cape and informed-decision; founder for release rollout | Exact deployed issuer revision and client/profile receipt for requester and human paths; human sub/type, tenant/source, resource audience, scopes and assurance captured as non-secret facts and correlated with a stored human-control entry. Promote scanned schema-v6 image under per-change approval, back up/migrate once, then verify native entry and issuance/use provenance. No tokens or secrets in receipts. | -| T04 — delivery and reconciliation | audit-core, with railiance-platform for deployed sender/custody acceptance | Accept current cadence.yaml inventory for source approval-engine and tenant tenant:platform, explicit redact policy and four transition heartbeat classes at the requested 172800s bound. Return actual lifecycle event ids/drain acknowledgment and independent accepted counts for an agreed bounded comparison, accounting for occurrence versus receiver acceptance time, delayed delivery, duplicate acknowledgments and boundary crossings; source pending rows remain lag. Prove startup beats, missing-class and lag findings (depth 100 / age 3600s), and scoped sender bearer revocation. | +| T04 — delivery and reconciliation | audit-core, with railiance-platform for deployed sender/custody acceptance | Accept current cadence.yaml inventory for source approval-engine and tenant tenant:platform, explicit redact policy and four transition heartbeat classes at the frame-derived 2f=7200s registered wait (effective 3f=10800s after receiver grace). Return actual lifecycle event ids/drain acknowledgment and independent accepted counts for an agreed bounded comparison, accounting for occurrence versus receiver acceptance time, delayed delivery, duplicate acknowledgments and boundary crossings; source pending rows remain lag. Prove startup beats, missing-class and lag findings (depth 100 / age 3600s), and retain the delivered September 15 scoped sender bearer-revocation receipt. | | T05 — live PEP consumption | secrets-engine, with informed-decision for the act-specific human review and access-engine for decision binding | Name the served PEP route/workload, immutable component revision, engine image/endpoint, authenticated caller identities and exact binding. Prove claim-before-decision and CAS consume after ALLOW before protected side effect; same-digest retry, different-digest conflict, spent-on-failure, revoked/expired/superseded/wrong-binding/unavailable denial with no protected callback. Preserve the native approval and decision digests, declared human_control where required, and consumer-owned side-effect/refusal evidence. | For live whitehat T-06 only, T05's named surface must additionally be covered by @@ -76,3 +76,11 @@ population and reconcile lag/duplicate/boundary cases; matching since/until strings against occurrence counts is insufficient. Existing owner task bindings remain key-cape (T01), audit-core (T04) and secrets-engine (T05). No new owner receipt or named live T-06 surface arrived since the preceding review. + + +2026-10-15 timing follow-up: founder selected shared coordination f=1h, +replacing a fixed day for this adoption. Generated source/config bindings are +prepared, not deployed. Canonical base is coordination-engine/config/coordination-frame.json; +NetKingdom retains regeneration and drift checks. Lifecycle drain/count-window, +registry admission and independent observation remain open. No credential, +JWT/approval validity, issuer scope or production image pin is changed. diff --git a/tests/test_cadence.py b/tests/test_cadence.py index 6e40cf0..ee42716 100644 --- a/tests/test_cadence.py +++ b/tests/test_cadence.py @@ -2,6 +2,7 @@ import json from pathlib import Path from approval_engine.audit import audit_envelope +from approval_engine.coordination import FRAME_SECONDS, RECEIVER_WAIT_SECONDS from approval_engine.store import EVENT_CLASSES, HEARTBEAT_CLASSES ROOT = Path(__file__).resolve().parents[1] @@ -30,8 +31,16 @@ def test_cadence_inventory_matches_durable_emitter(engine): assert beat["event_class"] == observed[cls]["type"] == "audit-core.heartbeat" assert beat["assertion"] == observed[cls]["data"]["assertion"] assert beat["extensions"]["approval-engine"]["asserted_class"] == cls - assert beat["interval_seconds"] == 86400 - assert beat["extensions"]["approval-engine"]["requested_observer_max_gap_seconds"] == 172800 + assert beat["interval_seconds"] == FRAME_SECONDS + assert beat["extensions"]["approval-engine"]["requested_observer_max_gap_seconds"] == RECEIVER_WAIT_SECONDS assert entries[cls]["extensions"]["netkingdom"]["rate_monitoring"] == "forbidden" assert entries[cls]["reconciliation"]["undrained_local"] == "lag-not-divergence" assert "heartbeat" not in entries["heartbeat"] # transport control has no recursive heartbeat + + +def test_default_runtime_heartbeat_matches_declared_coordination_frame(): + from approval_engine.cli import _parser + + args = _parser().parse_args(["serve"]) + assert args.heartbeat_seconds == FRAME_SECONDS + assert _parser().parse_args(["serve", "--heartbeat-seconds", "600"]).heartbeat_seconds == 600