approval-engine/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md
tegwick bfb1e66646 Heartbeat per event class, not per source
audit-core's completeness contract landed, and our single per-source beat
is the shape it rules inadequate: it is discharged by whichever class is
busy, so a revocation stream that has gone silent looks identical to a
quiet one — and revocation is the only silence here that matters.

Emit one nothing-to-report assertion per declared class, all four in one
transaction so a partial emission cannot report some classes healthy and
others stalled. Carry type audit-core.heartbeat with class and assertion
on data. Pin that the first beat goes out at startup rather than an
interval later, since a declared-but-never-sent class is their
no_heartbeat_since_registration finding and not a skip.

Declare heartbeat_classes and the reconciliation surface in the source
registration, including the residual neither control covers: a
compromised emitter suppresses the event and its own heartbeat together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyybaE7DUXrWYrhbnESCTe

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1275879@bnt-lap001
Assistant-Session: eb464208-f821-41b2-bc5a-a6c33d92a8ad
2026-09-10 20:36:21 +02:00

42 KiB

id type title domain repo status owner topic_slug created updated reviewed_at reviewed_against_commit reviewed_note origin origin_ref state_hub_workstream_id
APPROVAL-WP-0002 workplan Production readiness and consumer adoption infotech approval-engine active codex netkingdom 2026-09-01 2026-09-10 2026-09-01 ebce5abb27 Reviewed against approval-engine's finished spine, KeyCape's RS256/JWKS and service-token contract, access-engine caller-auth/binding surface, audit-core's deployed authenticated ingestion plus open AUDIT-WP-0009 approval-source/cadence/reconciliation tasks, and secrets-engine's waiting exact-action/decision-consumption tasks. Repo-owned implementation can proceed; live T03-T05 closure remains evidence-gated on those owners. residual APPROVAL-WP-0001 4fa25ad5-f5d0-5592-aa59-085f8ee3edaf

APPROVAL-WP-0002 — Production readiness and consumer adoption

Move the completed first-cut engine spine into an authenticated, durable, observable production service and prove one PEP integration end to end. This is the residual production scope deliberately excluded from APPROVAL-WP-0001.

The workplan is proposed pending review against the deployment estate and the current key-cape, access-engine, audit-core, and secrets-engine contracts.

Review completed 2026-09-01. The plan is active. Production mode will verify KeyCape JWT signatures and exact issuer/audience/scopes; local caller-supplied identity never becomes authenticated evidence. SQLite remains the first production store only as a single-replica StatefulSet with explicit migration, backup, integrity, and restore gates. audit-core delivery can be implemented against its existing authenticated idempotent ingest, while heartbeat findings, count reconciliation, and sender registration remain external gates in AUDIT-WP-0009 T04/T06/T09. The first live PEP proof remains jointly gated on secrets-engine T04/T02 and deployment of this service.

Authenticate lifecycle mutations and approver evidence

id: APPROVAL-WP-0002-T01
status: progress
priority: high
state_hub_task_id: "dc4523f5-e0af-5734-a0ef-07aa7f2b2a27"

Bind create, approval-entry, revoke, supersede, and consume callers to authenticated identities. An API-supplied subject_id, actor, or decision_id is provenance only until independently authenticated. Keep authorization decisions in access-engine and approval doctrine in gate-house.

Acceptance: production startup requires a signature-valid KeyCape JWT verifier; every non-health API route requires an explicit scope; approval entry identity and assurance come only from verified claims; create binds binding.actor to the authenticated subject; consume is restricted to service/agent principals; missing, expired, wrong-issuer, wrong-audience, wrong-scope, or unverifiable tokens fail closed without mutation.

Repository implementation complete 2026-09-02: RS256/JWKS verification, issuer/audience/time/profile validation, exact scopes, store-tenant isolation, verified approver evidence, and a deny-all default are covered by tests.

2026-09-02 follow-up: fail-closed coverage now includes wrong signature, HS256, empty/invalid profile claims, deny-all mutation refusal, human-principal consume rejection, and production CLI refusal of static tokens / missing audit delivery. Requested KeyCape registrations are in docs/keycape-service-registrations.md (aud MUST be the resource server approval-engine, not the OAuth client id). Remains progress until KeyCape owns and proves those audience/client/scope registrations.

2026-09-06 follow-up: a tenant collision in the deployment inputs is now recorded in docs/keycape-service-registrations.md. The manifest serves --tenant platform while the requested client registrations issue tenant: tenant:coulomb, and ApiApplication.identity compares the two with exact string equality before any object lookup — so tokens issued under the current registrations would be denied 403 on every non-health route. flex-auth's tenant:platform CheckRequest subject is a PDP input this engine never reads and cannot participate in the comparison. Denial evidence: tests/test_auth.py::test_wrong_tenant_is_forbidden. The values are left as-is deliberately: resolving it requires an owner statement on whether platform and tenant:coulomb name the same layer, and guessing grants cross-tenant access to the approval store. The registrations doc's stale issuer (https://auth.netkingdom.local) is corrected to the live https://kc.coulomb.social from 06544b0. T01 stays progress.

2026-09-06 resolution: the operator accepted tenant:platform as the platform management/administration/services tenant (landlord zone) — decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6, glas-harness/docs/platform-tenant-decision.md, relayed by glas-harness. The collision above is closed by setting the store tenant to exactly tenant:platform: deploy/approval-engine.yaml --tenant, the approval_engine/cli.py --tenant default, and the Engine(tenant=…) default all move off bare platform, and the requested client registrations now ask for tenant: tenant:platform. Exact JWT/store equality is retained — no alias, no normalisation, no prefix handling, and no implicit cross-tenant grant.

Moving the defaults rather than only the manifest is deliberate: a default of platform under a sanctioned value of tenant:platform is a trap, because a serve that omits --tenant would come up healthy and then refuse every authenticated call.

The default change broke ten tests whose identity fixtures hard-coded platform, which is the fixture-consistency hazard flex-auth reported as FLEX-DEC-2026-008 (29 fixtures all carrying one tenant proved nothing about the field). Fixtures are aligned to the exact spelling, and the field is now varied rather than merely present: tests/test_auth.py::test_near_miss_tenant_spellings_are_forbidden refuses platform, tenant:coulomb, case variants, whitespace variants and empty against a tenant:platform store, and test_exact_sanctioned_tenant_is_admitted pins the other half so a reject-everything bug cannot pass. 111 tests pass.

This resolves the choice of value only. T01 stays progress: KeyCape still has to own and prove these registrations, and credential materialization is unchanged.

2026-09-08 follow-up: key-cape (KEY-WP-0013-T02) asked for the human approver client's exact client_id and full callback URI. Neither is supplied, and neither is guessed: this engine is a bearer-token resource server with no redirect endpoint, no authorization-code/PKCE code and no Ingress or external origin (deploy/approval-engine.yaml is Namespace + ClusterIP + StatefulSet), so no callback URI exists here to register — and redirects match exactly at /authorize, so an invented value either fails closed or registers an origin no component owns. The human browser client belongs to an approver UI that this repo does not contain and APPROVAL-WP-0002 does not name; the two strings must come from that component's owner. Confirmed to key-cape in the same reply: this resource validates the access token (Authorization: Bearer, RS256 via the issuer's /jwks, checking iss, aud=approval-engine, expiry, sub, principal_type, tenant, roles, scope, assurance); id_token appears nowhere in the codebase. Recorded in docs/keycape-service-registrations.md. T01 stays progress.

2026-09-09 follow-up: the operator established informed-decision (INFD-WP-0001) as the owner of the browser-facing approver surface, closing the unassigned dependency recorded the previous day. Requirements issued to it in docs/approver-surface-requirements.md: the two routes an approver surface needs, the hard token claims, error/disposition mapping, and the standing refusal to expose an inbox listing or a decision. Three items need an owner's answer rather than an implementation: (A) the human client's specified allowedScopes [openid, approval:approve] cannot GET /v1/approvals/{id}, so the surface could submit an entry it was never able to render — we recommend adding approval:read to the human client over reading through a BFF service client, and this needs a key-cape decision; (B) assurance is persisted verbatim and is the only place mfaRequired: true survives into the approval record, so key-cape and informed-decision owe it a documented shape; (C) informed-decision's view_hash has nowhere to ride into an entry, whose body is deliberately discarded — v1 correlates by (approval_id, subject, approved_at) via audit-core, and binding the hash into the entry itself would be a gate-house doctrine question before it is a change here. T01 stays progress; none of these block this repository's half.

2026-09-09 (second): took T01 as far as this repository can reach. Writing the approver-surface requirements exposed a gap in T01's own "approver evidence" clause: an entry recorded subject_id, assurance and evidence_ref but nothing about what kind of principal bound the approval, and subject_id is a naming convention rather than a verified claim. /entries is not restricted by principal type — only /consume is, to service/agent — and the approval-engine-operator client holds approval:approve, so a service principal can supply approver evidence today and was until now indistinguishable from a human in the evidence chain. Whether a non-human may approve at all is approval doctrine and belongs to gate-house; making it legible is ours. Schema v4 adds entries.principal_type, populated only from the verified token, surfaced on the object and on the audit evidence path but deliberately not on the least-disclosure claim. Legacy entries stay null rather than being back-filled into a human claim nobody made — the same reasoning as pdp_path under GH-DEC-2026-008. Tests: test_entry_records_the_verified_principal_type, test_non_human_approver_is_recorded_as_such, test_v3_entries_migrate_to_v4_without_inventing_a_principal_type, and the v2 migration test now asserts the current version rather than a hard-coded 3. 124 tests pass. The existing migrate initContainer in deploy/approval-engine.yaml covers the upgrade, but the manifest's pinned image digest predates v4 — this change ships only with a re-pinned release image. Also corrected two errors in the requirements issued earlier today: agent tokens are not barred from approval:approve, and an empty assurance object is accepted rather than refused. T01 remains progress: what is left is key-cape owning the registrations, credential custody from railiance-platform, and the open scope question (A) above — none of them reachable from here.

2026-09-09 (third): railiance-platform (RPF-WP-0035-T06) asked who presents approval-engine-operator and refused to guess — correctly, since it carries the widest scope set of the pair. The answer is that nobody does. Nothing in this repository obtains an OAuth token: the engine only verifies them, its one outbound credential is the mounted audit-token for audit-core delivery, and pep.py is a consumer-side helper for PEPs that present their own. Scope by scope the bundle does not describe a single actor — approval:approve now belongs to informed-decision's human client, approval:emit is redundant because the server emits its own heartbeat with no token, and no requester identity has ever been settled for approval:create. It was a convenience bundle written when this repo assumed a lifecycle operator would exist. Answered (b): cancel CCR-2026-0020 as an owner decision rather than leave it in_flight; verifier custody CCR-2026-0018 is unaffected and stays correct. Recorded in docs/keycape-service-registrations.md, with the rule that a future presenter gets a registration narrowed to its own scopes and that approval:approve must not travel with the operational scopes — schema v4 makes a service bind legible, but legibility is not permission, and the should-it question remains gate-house's. secrets-engine-approval is unaffected: it has a real presenter and is the client that unblocks T05. T01 stays progress.

2026-09-10: three inbound items, all answered; one is a live blocker on the human client and it is not ours to fix. (i) key-cape reports that a human access token cannot carry tenant:platform today: effectiveTenant(user) resolves the claim from the directory record, domain.User.Tenant is populated by no adapter, and every human token falls back to tenant:coulomb; the per-client tenant field is read only on the client-credentials path. Our exact equality would refuse such a token, surfacing as a rejected approval rather than a registration defect. We answered (2) registration-bound, matching decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 and symmetric with the service clients, and only because registrations are static, deployment-owned, and dynamic client registration is excluded by design. (ii) gate-house GH-DEC-2026-013 names the consequence of exactly that: a registration-supplied tenant is the client's say-so, not the directory's, and exact matching cannot see the difference — the same shape as our own pdp_digest finding one layer down. They were right that our wording implied a fact about the person. docs/caller-authentication.md now states that the check is store isolation and not membership, that a registration-supplied tenant is admissible for admission but not for any future doctrine turning on the approver's own membership, and that provenance will be recorded on the entry the way v4 records principal_type once the claim carries it. (iii) informed-decision R3, answered in docs/approval-claim.md: view_hash and binding.digest are not the same hash and must not be merged. Three hashes, three questions — which act (five fields), which decision request (pdp_digest), what a person was shown (view_hash). Recommended that their binding document carry our digest as a field rather than re-canonicalize the same five fields, so the estate has one canonicalization of the act with a detectable mismatch instead of two independently correct answers. Also accepted from key-cape: approval:read added to the human client, and a documented assurance shape (level/methods/mfa/source/at) — with at fixed to carry the original authentication instant through session reuse, which matters here because this engine stores that object verbatim as the only downstream record that MFA happened. T01 stays progress; the human client cannot be registered until the tenant source is settled by its owner.

2026-09-10: GH-DEC-2026-016 supplies the previously missing NC-03 doctrine. Approvals explicitly declared as discharging human-in-the-loop or dual-control requirements must refuse non-human binds at issue. T01 owns the declaration, engine/API enforcement, predecessor inheritance and negative legacy migration, verified human identity provenance, tests and subsequent deployed proof. This return changes neither existing rows nor production admission. The new rule is not satisfied merely by the already-recorded principal_type field. The separate R3 digest-exclusion condition is completed in T06 below.

Declared human-control implementation — 2026-09-10

GH-DEC-2026-016 source implementation now records strict human_control intent at request creation and refuses non-human binding at add_entry / /entries. The creator may still be a service drafting a request; approval judgment comes from the verified entry identity. This maps the ruling's bind/issue boundary to this engine's two-step API and does not confer approval on an unapproved draft. Schema v5 defaults legacy rows to undeclared, preserves act digests and recorded entries, and inherits the declaration on supersession. An existing successor with a mismatched declaration conflicts atomically. Spoofed request-body identity cannot change the verified JWT type; invalid persisted human evidence also fails claim/consume. The outbox retains the declaration with its existing atomic write.

All 22 new cases fail on the previous implementation and pass after correction; the full suite passes 152 tests. Persistent v4 migration, RS256/JWKS HTTP calls, ordinary service compatibility, human quorum, rollback and successor cases are included. Actual Secrets Engine claim parsing accepts both new flag values; this is wire compatibility, not adoption of the new requirement. KeyCape's current source fixes human type on its PKCE/user path and service type on its client-credentials path; native issuer/human proof is still outstanding.

T01 remains progress for exact requester/human registration and native proof. T03 owns the matching schema-v5 image and admitted deployment. T05 with SECRETS-WP-0009-T03 and INFD-WP-0001-T08 must explicitly declare and require human_control for the factory human-approval workflow; old generic approvals must not be treated as satisfying it. Evidence: docs/evidence/2026-09-10-human-control.json. No production approval or database migration occurred during these tests.

Harden durable storage and migrations

id: APPROVAL-WP-0002-T02
status: done
priority: high
state_hub_task_id: "2bef94ca-9482-5eff-9bd9-39adef292a78"

Define the production persistence, backup/restore, migration, concurrency, and recovery posture. Prove schema upgrades preserve existing approvals and that crash recovery cannot separate mutations from outbox evidence.

Acceptance: schema version is explicit; production serve refuses an unmigrated or in-memory store; migration is a separate repeatable command; backup uses SQLite's online backup API and integrity verification; restore is documented as a stopped-single-writer operation; tests cover legacy upgrade, backup/restore, and outbox atomicity after restart.

Completed 2026-09-02: schema v2 migration, production no-auto-migrate gate, integrity/status surface, online mode-0600 backup, stopped-writer restore runbook, retry-attempt state, and migration/backup/atomicity tests are in place.

Package and deploy the service

id: APPROVAL-WP-0002-T03
status: wait
priority: high
state_hub_task_id: "f0aa2e6d-19e6-5b43-886c-efa4e3de5f22"

Add the governed image/deployment surface, health and readiness behavior, resource bounds, and fail-closed caller configuration. A local WSGI development server is not production evidence.

Acceptance: a digest-pin-ready image and single-writer StatefulSet manifest exist with non-root/read-only-root controls, PVC, migration init container, resource bounds, probes, and default-deny network policy. Live completion also requires an immutable image digest, KeyCape registrations, audit sender credential, successful rollout, and restart/restore evidence.

Repository implementation complete 2026-09-02: the digest-pin-ready non-root image builds and runs; the single-writer StatefulSet, PVC, migration init, read-only root, resources, probes, and default-deny policies pass client dry-run. Waiting on release digest, KeyCape/audit registrations and credentials, rollout, restart, and restore evidence.

2026-09-06 image preparation (GLAS-WP-0015 request; credential-independent half): built and validated locally against the agreed tenant and current schema.

  • Build: make image-build off the digest-pinned base python:3.12-slim@sha256:d764629c…. Local manifest-list digest sha256:85e46ddf47b3ac0cdab620163a7b034027451dff286b63ecff6a2493d7a57ecc. This is a local build digest, not a release digest — the manifest still carries REPLACE_WITH_RELEASE_DIGEST because pinning requires a push to forgejo.coulomb.social, which needs registry credentials this session does not hold.
  • Runtime identity: uid=10001(approval) gid=10001(approval), non-root as required.
  • Schema: image carries LATEST_SCHEMA_VERSION = 3, matching the migrated store, and Engine tenant default tenant:platform.
  • First-install migration (no prior DB): migrate then verify on a fresh volume both report schema_version: 3, schema_current: true, integrity: ["ok"], foreign_key_violations: 0, persistent: true.
  • Fail-closed configuration proven in the image, not only in tests: serve --production --db :memory: refuses with "production requires a persistent database"; serve --production on a real DB without audit configuration refuses with "production requires authenticated audit delivery".
  • Manifest inputs: kubectl apply --dry-run=client passes for the namespace, service, and StatefulSet with the new --tenant tenant:platform.

Scan gate NOT met: no scanner (trivy, grype, docker scout) is installed on this workstation, so no vulnerability scan was run and none is claimed. The inventory a scanner needs is the pinned base above plus cryptography==50.0.1, PyJWT==2.13.0, waitress==3.0.2, cffi==2.1.1, pycparser==3.0, pip==25.0.1.

T03 stays wait: still no release digest, no KeyCape/audit credentials, no rollout, and no restart/restore evidence. Nothing here is a deploy.

2026-09-06 scan gate — run, and it failed on the sanctioned base. Full record in docs/image-scan-2026-09-06.md. Scanned with trivy (no scanner was installed; this one needed none). Findings are all inherited from the base image, none in approval_engine code.

Two fixes applied. The base pin was stale at Debian 13.5 while upstream is 13.6 — bumping the digest removed 30 HIGH and 47 MEDIUM. And pip, which held all 10 MEDIUM Python findings, is now absent from the runtime image via a two-stage build; it is a build-time tool and had no business in a running approval service.

What remains is a decision, not a task. Three CRITICALs persist on Debian, all perl-base (CVE-2026-13221, CVE-2026-42496, CVE-2026-8376), none with an upstream fix, in a package this service never invokes and which Debian marks Essential: yes. An Alpine variant carries no perl and scans 0 CRITICAL / 7 HIGH / 1 MEDIUM against Debian's 3 / 51 / 56. It is proven viable — musl wheels resolve without a toolchain, the full suite passes on musl at 111, and non-root identity, schema v3, tenant:platform, fresh-store migrate/verify and both production fail-closed gates all hold in the image. It is parked in Containerfile.alpine as a candidate; Containerfile remains the sanctioned base.

Nothing was pushed. Pushing the Debian variant would pin three unfixable CRITICALs into a release digest, and choosing the runtime C library for this service is not a call to make silently. deploy/approval-engine.yaml still carries REPLACE_WITH_RELEASE_DIGEST. Awaiting an owner answer on the base (Alpine, a documented perl exception on glibc, or distroless — unevaluated) and on which scanner is sanctioned.

2026-09-06 both answered; release candidate scans clean. The operator adopted Alpine/musl as the sanctioned base and trivy as the sanctioned scanner. Containerfile.alpine is promoted to Containerfile and the Debian variant is retired rather than kept as an option.

Promoting Alpine left 7 HIGH and 1 MEDIUM, all libuuid 2.42.1-r0 and all with fixes in 2.42.3. The runtime stage now requires libuuid>=2.42.3-r1 — a version floor rather than a floating upgrade, so the base stays digest-pinned and a vulnerable libuuid fails the build instead of shipping. The candidate now scans 0 CRITICAL / 0 HIGH / 0 MEDIUM / 0 LOW.

Verified on that exact artifact: non-root uid=10001, pip absent, schema v3, tenant default tenant:platform, fresh-store migrate+verify clean, restart persistence via re-verify on the same volume, both production fail-closed refusals, 111 tests on musl, and kubectl --dry-run passing.

The gate is now reproducible rather than a one-off: make image-scan fails on any CRITICAL or HIGH, and make image-release runs build then scan then push so a failing scan blocks the push by construction.

Still wait, and now for exactly one reason: docker push was attempted and refused by this session's sandbox as an outward-facing publish. It was not worked around. No release digest exists, so the manifest keeps REPLACE_WITH_RELEASE_DIGEST; an operator must run make image-release (or the push and docker inspect) and pin the returned registry manifest digest — never the tag, never the local image id sha256:73333f5ceb55e48192e3095cb2e2a741cdc6ff0be2f18128301072b4a6b6eb9d. Rollout, restart and restore evidence remain gated on the KeyCape registrations and audit sender credential.

2026-09-06 published (glas-harness, standing production authorization). The push above succeeded outside this repo's session. Registry tag 0.1.0 resolves to OCI index digest sha256:73333f5ceb55e48192e3095cb2e2a741cdc6ff0be2f18128301072b4a6b6eb9d, with linux/amd64 manifest sha256:2064d5378e01801ecf9966ccec9c93aadd16d6b404ef4890983d89fa738c164c. Both image: references in deploy/approval-engine.yaml pin the index digest at b51d174.

The index digest equals the local image id recorded above, which is a property of the containerd/OCI image store rather than evidence of anything — the publication evidence is the successful push and the remote index lookup, and this repo verified the digest resolves against the registry independently. An earlier caution in this workplan that the value was "a local image id, not a release digest" was wrong on that point; the digest is correct and the pin is sound.

2026-09-07 acceptance re-checked against the whole list, because a published image reads like a finished task. T03's acceptance requires an immutable digest and KeyCape registrations, an audit sender credential, a successful rollout, and restart/restore evidence. Only the first is now met. kubectl -n approval-engine get all returns nothing and no secrets exist, so nothing is deployed; production serve refuses to start without authenticated audit delivery, so a rollout attempted today would fail closed by design and would prove nothing. T03 stays wait — on T01's registrations and the audit credential, not on anything this repo owns.

2026-09-09: schema v4 (entries.principal_type, T01) gave this task a new drift surface, now closed in the only way that survives review. The manifest's pinned pair is self-consistent — the published artifact migrates to 3 and serves 3 — so nothing fails, and that is exactly the dangerous shape: the deployment reads healthy while the assumption "the deployment records approver principal type" is false. deploy/README.md now states that the pin predates schema v4 and that carrying the new evidence requires cutting a new image at gate 3, and tests/test_deploy_manifest.py::test_pinned_image_schema_drift_is_acknowledged holds the statement: while the manifest pins the artifact docs/image-scan-2026-09-06.md describes, that record's schema version must match this repository or the runbook must say plainly that it does not. Verified to fail when the acknowledgement is removed. docs/storage-operations.md gains the v4 section (additive ALTER TABLE, legacy entries stay NULL and are never back-filled to human, downgrade unsupported). T03 stays wait: gates 1 and 2 are unchanged and nothing is deployed. 125 tests pass.

Schema-v5 release candidate — 2026-09-10

The earlier schema drift is resolved in the published candidate, source be1a388a848cf59e9ff3a5420f88a70ca0a52e7e, registry tag 0.1.0-hfact-be1a388, immutable digest sha256:251941a5cb2724b57cc32cff6b693b1ab0be695bee4f56f02d51961189c0fa49. Both migration and serving references now pin it. The sanctioned HIGH/CRITICAL scan passes with zero findings. Disposable container checks prove schema v5, v4 migration without inferred human declaration, nonhuman bind refusal, human bind/claim/consume and restart persistence; packaged store bytes match committed source. These checks use synthetic local identities and no network. See docs/evidence/2026-09-10-human-control-image.json for the exact evidence.

T03 remains wait: native KeyCape requester/approver registration and audit sender custody still precede rollout, live persistence/outbox/restore proof. The factory requester and PEP must explicitly declare/require human_control in T05 with SECRETS-WP-0009-T03 and INFD-WP-0001-T08. No production database was migrated, no workload deployed and no factory/model request admitted.

Wire outbox delivery and reconciliation

id: APPROVAL-WP-0002-T04
status: wait
priority: high
state_hub_task_id: "777a5cf5-c1db-5e07-8de6-ab109db5ccdc"

Deliver the local outbox asynchronously to audit-core, preserve event-id deduplication, publish lag/depth signals, emit the declared heartbeat, and prove the Gate House reconciliation contract against accepted event counts.

Acceptance: the sender adapts the local event to audit-core's authenticated HTTP ingest, reuses event id as idempotency key, rereads a mounted token file, marks drained only on accepted/duplicate, retains retryable failures, exposes attempt/lag state, and emits the declared heartbeat. Live reconciliation waits on AUDIT-WP-0009-T04/T06/T09; do not invent that receiver surface here.

Repository implementation complete 2026-09-02: audit-core envelope adaptation, event-id idempotency, mounted-token reread, accepted/duplicate handling, retry-attempt/lag metrics, and periodic heartbeats are tested.

2026-09-02 follow-up: drain tests now cover HTTP 200 duplicate as drained and urllib HTTPError 503 as pending. Waiting on the audit-core sender registration/ingress and receiver-owned reconciliation work (AUDIT-WP-0009-T04/T06/T09).

2026-09-08 critical-path return: docs/audit-source-registration.md resolves Audit Core's tenant and redaction questions from the existing sanctioned store contract: exact tenant:platform, source approval-engine, write-only, load-bearing, explicit redact. The outbox example and envelope test now pin the same tenant. This closes those source-input waits, not live registration. Platform still owes linked receiver/sender custody and the protected token; T04 stays wait for admission and live drain/reconciliation evidence.

2026-09-10: audit-core landed the detection half (AUDIT-WP-0009 T04/T06/T07 at b098fb1, contract in their docs/stream-completeness.md), so the sender side is no longer describing a cadence that does not operate. Implemented the per-class heartbeat contract: HEARTBEAT_CLASSES covers issuance, use, supersession and revocation, each asserting nothing-to-report for itself, all four written in one transaction. The previous single per-source beat was exactly the shape audit-core rules inadequate — it is discharged by whichever class is busy, so a silent revocation stream hides behind issuance, and revocation is the only silence here that matters. Heartbeats now carry type: audit-core.heartbeat with class and assertion on data rather than inside our details object. The first beat is emitted at startup, not one interval later, because a declared-but-never-sent class is their no_heartbeat_since_registration finding rather than a skip — heartbeat_due already returned true on an empty outbox and that is now pinned by test. docs/audit-source-registration.md declares heartbeat_classes at 172800s for all four (twice the deployed 86400s default, so one missed cycle is not a finding and two are), records the reconciliation surface and both bounds, and restates the residual audit-core stated on every response: counts and heartbeats cover loss, outage, drain failure and accident, and neither covers this engine lying about itself, since a compromised emitter suppresses the event and its own heartbeat together. Tests: per-class emission including revocation, atomicity under outbox failure, the envelope placement, and that an ordinary event gains no heartbeat fields. 155 pass. T04 stays wait: live drain still needs the sender registration and credential admission, and the heartbeat_classes values above are a request until audit-core accepts them.

Prove one live PEP consumption path

id: APPROVAL-WP-0002-T05
status: wait
priority: high
state_hub_task_id: "3196fb77-3df0-5358-ae12-2d07bac12041"

Integrate one protected-system consumer under GH-DEC-2026-003: claim before decision, CAS consume after ALLOW and before side effect, same-digest retry, different-digest conflict, spent-on-failure behavior, and no protected action when approval-engine is unavailable.

Acceptance: a repeatable harness proves the PEP sequence against the real HTTP surface without performing a protected action; live closure requires a secrets-engine-owned handler and evidence that no OpenBao call occurs on every failure case. This repo may supply the protocol client and fixture, but may not claim the consumer's side effect.

Repository implementation complete 2026-09-02: the HTTP PEP client and fail-closed sequencing harness prove claim-before-decision and CAS-consume-before callback, including unavailable, DENY, digest mismatch, and conflict paths.

2026-09-02 follow-up: secrets-engine shipped the PEP consume-before-OpenBao handler (src/secrets_engine/approval_consume.py; inbox 0e04b4e2). This engine's client now maps HTTP 409/401/403/404/503 and unreachability, requires the canonical digest, and refuses a decision-shaped consume payload. The repeatable harness in tests/test_pep.py drives the real HTTP surface: first consume then same-digest retry, different-digest conflict, spent-on-failure claim refusal, and unreachable-engine callback suppression. Waiting on live closure: this service deployed (T03) and a durable consume binding served (SECRETS-WP-0007-T04 / SECRETS-WP-0008-T02). This repo does not claim the OpenBao side effect.

2026-09-06 follow-up: secrets-engine reports its PIP join is implemented and blocked on deployment, not contract (inbox 61ae1174). Review of its validate_action_authorization shows a real envelope divergence: it expects a state-hub-authority ActionAuthorization (id, status, superseded_by, request, approvals.entries, policy pin) while this engine serves the governed approval-claim (approval_id, state/valid_now, binding, freshness, reason_code, issuer: approval-engine). Both declare schema_version 0.1, so the mismatch surfaces as a field/authority error rather than a version error. Recorded in docs/approval-consumption.md; reconciling the envelopes is a GH-DEC-2026-003 cross-repo change, not a unilateral edit here. T05 stays wait: still no deployed base URL (T03).

2026-09-06 ruling: the claim-envelope question is settled. GH-DEC-2026-005 confirms all three requested dispositions — the approval-claim is the step-1 artifact, ActionAuthorization is not required and MUST NOT be served from the claim endpoint, and a PEP validates across the claim and the step-2 DecisionEnvelope. Gate House recorded the split as doctrine (a PIP must not republish the PDP's decision) and struck the provenance.authority == "state-hub" requirement explicitly. flex-auth accepted as FLEX-DEC-2026-006, argued against its own proposal, and traced the bad authority constant to a fixture rather than prose. This engine changes nothing: the claim schema stands as published. APPROVAL-IN-0002 is closed. T05 remains wait on T03 deployment plus the secrets-engine validator split and its secrets-engine-approval KeyCape registration (already requested verbatim in docs/keycape-service-registrations.md).

2026-09-06 follow-on (T01): GH-DEC-2026-005 assigned this engine a compensating obligation. secrets-engine has implemented the split and reports it no longer verifies the distinct-approver threshold independently; Gate House accepted that as correct on layering and as a real reduction in defence in depth, requiring instead that the threshold evaluation be reconstructable from this engine's state transitions and its use outbox row under §9.6. Implemented: approval.issuance and approval.use now carry a threshold object (required_count, distinct_approver_count, threshold_met, and approvers with approved_at plus assurance/evidence refs). Tests prove reconstruction from the use row alone and that the claim still discloses no approver identities. Documented in docs/outbox-contract.md.

2026-09-06 follow-on (T05): flex-auth asked whether this engine should publish an action/target vocabulary mapping between the claim binding and their policy package before SECRETS-WP-0007-T04 makes destroy reachable. Answered no, and recorded why in docs/approval-claim.md: a PIP asserting that one vocabulary's action means another's would author policy semantics it does not own, and a wrong mapping silently accepts a claim approved for a different action. binding.pdp_digest is the correspondence — it compares the PDP's digest to the PDP's digest with no translation. Implemented the part that was ours: pdp_digest is now always present on the claim and null when the approval was not issued against a PDP decision, so absence is a stated fact rather than a missing key, and the schema requires it as nullable. A PEP on a privileged lane must refuse a null. Both published examples were contradicting the schema; fixed, and tests/test_examples.py now validates every example against it.

2026-09-06 follow-on (T01/T05): implemented GH-DEC-2026-008, which requires binding.pdp_digest on the GH-DEC-2026-003 path and directed this engine to record the PDP digest at issue and have the claim state which approvals those are. Schema v3 adds approvals.pdp_path; create refuses pdp_path: true without a pdp_digest, so the failure lands at issue rather than at the protected side effect. The claim exposes binding.pdp_path, making pdp_path: true a guarantee that pdp_digest is non-null. Intent is declared, never inferred from an incidental digest, and never back-filled: legacy rows migrate to false and a successor inherits its predecessor's declaration. Schema, both examples, and a v2→v3 migration test cover it (102 tests).

2026-09-06 follow-up (envelope target): flex-auth published binding.approval_binding_digest (FLEX-DEC-2026-007) — the request digest material with context.approval removed — because a claim-bearing request's request_digest covers the carried claim and can therefore never equal a pdp_digest recorded at issue. A consumer obeying GH-DEC-2026-008 against request_digest would have failed closed permanently on every claim, which is the defect secrets-engine and flex-auth both hit. The value this engine records at issue is unchanged and correct; only the consumer-side comparison target needed naming. schemas/approval_claim.schema.json and docs/approval-claim.md now name approval_binding_digest as the execute-time target and state that request_digest is never it. No code change was required, so T05 stays wait on the deployed base URL (T03).

Production preflight — 2026-09-06 Glas deployment session

User authorized production deployment. Live cluster inspection confirms no approval-engine workload/service. KeyCape exists at service keycape in sso; live issuer is https://kc.coulomb.social. Corrected those two stale deployment inputs. Neither secrets-engine-approval nor approval-engine-operator appears in the live KeyCape client configuration. The audit sender-scope ConfigMap currently registers only user-engine. No credential values were emitted.

T03 remains wait: provision the two KeyCape registrations and protected client credentials, register/custody the approval-engine audit sender, align the store/client tenant contract, then build/scan and pin the release image. The manifest currently says platform while requested KeyCape registrations say tenant:coulomb (and the secrets-engine policy says tenant:platform); exact claim/store comparison requires an owner-consistent choice before activation. Do not substitute guessed values or start production without audit delivery. The secrets-engine PDP was independently deployed by FLEX-WP-0021-T04; this does not satisfy approval service readiness.

Registry publication — 2026-09-06, Glas deployment session

The previously blocked push succeeded under the operator's standing production deployment authorization. Registry tag 0.1.0 resolves to OCI index digest sha256:73333f5ceb55e48192e3095cb2e2a741cdc6ff0be2f18128301072b4a6b6eb9d. The linux/amd64 image manifest is sha256:2064d5378e01801ecf9966ccec9c93aadd16d6b404ef4890983d89fa738c164c. Both deployment images now pin the registry index digest, verified remotely following publication. Its equality to the earlier local image identifier is not the evidence of publication; the successful push and remote index lookup are.

No StatefulSet rollout yet: live KeyCape registrations and audit sender/token admission remain outstanding. APPROVAL-WP-0002-T03 stays wait for those gates.

Make presentation exclusion a tested binding contract

id: APPROVAL-WP-0002-T06
status: done
priority: high
state_hub_task_id: "1c6d6936-1a16-5f41-ba7d-5cfbd4231bf9"

GH-DEC-2026-015 §4 conditions informed-decision's nested binding.digest on an explicit normative presentation exclusion and a regression that exposes widening. The native digest MUST cover exactly the five act fields and MUST NOT acquire presentation fields. Callers keep presentation outside effect scope; arbitrary nested target scope is preserved, not silently filtered.

Completed 2026-09-10: docs/approval-claim.md states the rule. The durable issue/claim regression varies view_hash, brief, packet, highlights, locale, UI release and a presentation wrapper while proving the stored act/digest remain unchanged. It also varies all five act fields independently and requires the digest to change. A deliberate whole-binding canonicalization mutation fails the regression; the production algorithm is unchanged. Evidence: docs/evidence/2026-09-10-presentation-exclusion.json. This supplies the engine condition for INFD-IN-0004; actual informed-decision adoption remains its existing work. T01 retains GH-DEC-2026-016 and native identity, T03/T05 deployment/real consumption remain open. No live approval was issued.