Phase 2a shipped a runnable policy engine that answered access / mask / assist / view-as / sensitive questions deterministically — but it trusted any non-empty approval_ref at face value, accepted only dev X-PTT-* headers, and had no audit envelope. Phase 2b closes four honest gaps without crossing into write-path territory:
approval_queue_model.queue_shape.item_fields.sla_due_at and dual-signer rules — consumed by new endpoints that do NOT alter Phase 2a behaviour.audit_event_model.base_shape without delivering to Kafka or WORM.This moves Admin one honest step closer to production while keeping every phase-2a guarantee intact.
| Dimension | Phase 2a | Phase 2b (additive) |
|---|---|---|
| Auth context | X-PTT-* headers · query · body | Authorization: Bearer <jwt> > headers > body > query · decode-only unless PyJWT+POLICY_JWT_VERIFY=true+key · service.auth_source added |
| Approval handling | _active_approval() trusts any non-empty string |
New: GET /api/policy/approvals/{id} · POST /api/policy/approvals/validate · POST /api/policy/sensitive.check.2b consults file-backed store · TTL + state enforced · existing Phase 2a endpoints unchanged |
| TTL / expiry | not enforced · strings accepted regardless of sla_due_at | ttl_remaining = max(0, sla_due_at - now) · past sla_due_at → expired=true · prec-8 fail-safe · dual-signer rows need signed_full |
| Audit boundary | stdout JSON per request | New POST /api/policy/audit/preview renders audit_event_model.base_shape · sink_status='deferred' · stdout NDJSON mirror |
| Health | ready/degraded + counts | New GET /phase-2b/health adds approval_store_loaded · approval_count · pyjwt_installed · jwt_verify_live · audit_sink_status |
| Root response | phase='2a' · 7 endpoints | phase='2a (base) + 2b (additive)' · 12 endpoints · both contracts listed |
| Policy engine | 5 decision functions · 24 canonical examples | unchanged · 24/24 preserved · new additive helpers validate_approval_row() + sensitive_check_2b() |
| Hard dependencies | fastapi · uvicorn | same · PyJWT is commented optional in requirements.txt |
ptt.audit.trail producer · WORM object-lock · hash-chain signing
Phase 2a reserved auth_strategy.phase_2b_plan (RS256 · JWKS · claims per role_registry.jwt_claim_shape). Implementing the Bearer resolver now — even in decode-only mode — means:
sub/role/tenant_id.service.auth_source so grep on a log tells operators which path was taken.warnings: ['auth_not_verified'] · impossible to silently confuse dev with prod.Verification conditions (all three required): PyJWT installed · POLICY_JWT_VERIFY=true · POLICY_JWT_PUBLIC_KEY or POLICY_JWKS_URL. Any missing precondition keeps the service in honest dev mode.
Phase 2a's sensitive_check relied on _active_approval() which trusts any non-empty string. That is fine for contract testing but cannot honour dual-signer rules, TTL, withdrawn/rejected states, or matrix_row cross-checks. A file-backed store is the minimum-viable concrete representation matching exactly the queue_shape.item_fields Phase 2c will persist in Postgres.
Row shape authority: docs/runtime/admin-control-plane/approval_queue_model.json#queue_shape.item_fields is canonical. approval_store_schema.json mirrors it field-for-field; any divergence is a bug.
Read-only stance: no create / sign / withdraw endpoints in this phase. Mutation is Phase 2c. The store is loaded once at startup; no hot reload. Store writes stay blocked even if the file is edited — the running process sees only the snapshot taken at boot.
sla_due_at doubles as the TTL deadline — it is the single source of truth for "still consumable". The engine computes ttl_remaining at read time rather than relying on a background sweeper flipping state. This is intentional for Phase 2b:
sla_due_at receives expired=true.access_policy.precedence_rules.prec-8: "Any unmask without active approval_ref OR expired TTL → masked".approval_queue_model.expiration_rules: expired items cannot auto-revive; resubmit is Phase 2c.
Dual-signer rule: state='signed_partial' is valid only when required_signer_count=1. Dual rows (=2) require signed_full. The reason code dual_signers_required is emitted on the boundary so callers see exactly why.
The full audit pipeline needs Kafka SASL, WORM bucket, hash-chain keys, and a query API — all Phase 2b+ infra. But downstream runtimes already want to see the exact envelope shape they will eventually need to emit. POST /api/policy/audit/preview answers that:
audit_event_model.base_shape populated from request context.sink_status: 'deferred' on every response · impossible to mistake for real delivery.event_types · warnings if the type is not in the model.Audit read access control, hash-chain signing, and the real producer wait for Phase 2c.
| File / route | Owner | State in Phase 2b |
|---|---|---|
docs/runtime/admin-control-plane-service/phase_2b_contract.json | A | new · Phase 2b contract |
docs/runtime/admin-control-plane-service/approval_store_schema.json | A | new · JSON-Schema draft-07 |
docs/runtime/admin-control-plane-service/approval_examples.json | A | new · 8 seed rows |
docs/runtime/admin-control-plane-service/audit_sink_contract.json | A | new · audit sink boundary |
docs/runtime/admin-control-plane-service/audit_event_examples.json | A | new · 5 grounded examples |
docs/runtime/admin-control-plane-service/app.py | A | extended append-first · 4 new endpoints + Bearer resolver + store loader |
docs/runtime/admin-control-plane-service/policy_engine.py | A | extended append-first · 2 additive helpers · existing 24/24 functions unchanged |
docs/runtime/admin-control-plane-service/phase-2b.html | A | new · runtime debug surface |
docs/runtime/admin-control-plane-service/policy_contract.json | A | unchanged · Phase 2a contract stable |
docs/runtime/admin-control-plane/*.json | A | Phase 1 models unchanged · consumed read-only |
docs/runtime/feature-flags-service/phase_2b_contract.json | A | sibling · pattern mirror · unchanged |
docs/kb/data/approval_matrix.json | B | read-only · row-sensitive-override + dual_approval_rules reference |
docs/kb/data/tenant_scope.json | B | read-only reference |
docs/kb/data/publish_workflow.json | B | read-only reference |
docs/kb/data/sla_model.json | B | read-only · sla_band ids referenced in rows |
client → admin service Bearer resolver: verify (PyJWT + flag + key + aud) → auth_source='jwt' else decode-only → auth_source='jwt_unverified' + warning else → skip Context merge: claims > headers > body > query > defaults IF endpoint == /api/policy/approvals/{id}: store lookup → 404 if absent → validate_approval_row(row, now) → {valid, expired, ttl_remaining, reasons} IF endpoint == /api/policy/approvals/validate (POST): for each approval_id in body.approval_refs: store lookup validate_approval_row → per_ref entry return {per_ref[], any_valid, any_expired, all_valid} IF endpoint == /api/policy/sensitive.check.2b (POST): compute required_approvers from mask_row for each approval_ref: validate_approval_row → expired / state check decision = allow iff any_valid AND matrix_row matches writes by platform_role still denied (same as Phase 2a) IF endpoint == /api/policy/audit/preview (POST): render envelope matching audit_event_model.base_shape sink_status='deferred' · stdout NDJSON mirror NEVER contacts Kafka IF endpoint in Phase 2a set (access · mask · assist · viewas · sensitive · batch · health): → Phase 2a policy_engine functions (unchanged · 24/24) client ← JSON envelope {ok, data, error, service{auth_source, warnings}}
/runtime/admin-control-plane-service/ — sibling to phase_2b_contract.json, the existing service.html, README.md, and the new phase-2b.html. The word Dashboard is not used (reserved per IA hard rule #5)./planning/admin-control-plane-phase-2b.html, sibling to admin-control-plane.html.idx-planning-admin-2b at 03.15.00.00idx-runtime-admin-service-2b at 04.03.10.0004.03.10.01..04 (contract · approval schema · approval examples · audit contract · audit examples)| Item | Deferred? | Current state | Why deferred | Next logical phase |
|---|---|---|---|---|
| JWT verification (JWKS/IdP) | yes | decode-only unless 3 preconds | IdP + key rotation infra | Phase 2b+ infra |
| Fallback dev X-PTT-* headers | no (intentional) | live for dev ergonomics | retire when verified JWT is on in prod | deployment phase |
| Approval persistence backend | yes | file-backed JSON · loaded at startup | Postgres not provisioned | Phase 2c |
| Approval TTL enforcement backend | yes | compute-at-read · no background sweeper | scheduler infra not in scope | Phase 2c |
| Audit sink delivery (Kafka) | yes | preview-only · stdout NDJSON | Kafka producer not wired | Phase 2b+ infra |
| Real signer workflow / mutation UI | yes | READ-only · 8 seed rows | admin CRUD + UI phase | Phase 2c + 3 |
| Real approval issuance flow | yes | not implemented | depends on admin UI | Phase 2c |
| WORM persistence + hash-chain | yes | contract documents it | object-lock bucket + signing key not wired | Phase 2b+ infra |
| Rate limiting | yes | none | dev-mode localhost binding | Phase 2b+ infra |
| Production deployment hardening | yes | localhost only | dev-mode by design | deployment phase |
bash run.sh on port 8090 with no extra install beyond the existing requirementswarnings: ['auth_not_verified'] surfacedaudit_event_model.base_shape for all 20 event_types · sink_status='deferred' on every response