Admin Control Plane · Phase 2b 15 · JWT + approval store backing + TTL enforcement + audit sink boundary · additive on top of Phase 2a · 24/24 parity preserved

Phase statement ข้อกำหนดเฟส

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:

This moves Admin one honest step closer to production while keeping every phase-2a guarantee intact.

Delta from Phase 2a ความเปลี่ยนแปลงจาก 2a

DimensionPhase 2aPhase 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

Scope vs non-scope ขอบเขต

In scope (live after this commit)

  • JWT claim expectation documented · dev Bearer resolver live · decode-only unless 3 verification preconditions all met
  • File-backed approval store with 8 demo rows (valid · dual-partial · dual-full · expired · pending · withdrawn · rejected · sensitive-approved) · schema-validated · read-only
  • TTL enforcement live via new endpoints · existing 2a paths unchanged
  • Audit sink BOUNDARY · preview endpoint with envelope grounded in audit_event_model.base_shape · 5 canonical example envelopes
  • Phase 2b runtime debug · planning page · catalog entries · cross-links · SYNC append

Non-scope (deliberately deferred)

  • Real JWT signature verification via JWKS/IdP at runtime
  • Approval CRUD (issuance · sign · withdraw · transition)
  • Background TTL sweeper flipping state='expired' at sla_due_at
  • Kafka ptt.audit.trail producer · WORM object-lock · hash-chain signing
  • Real admin mutation UI · signer Kanban
  • Rate limiting · JWKS rotation handling

JWT / verified-auth boundary rationale ทำไมต้องมี JWT boundary

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:

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.

Approval store rationale ทำไมต้องมี store backing

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.

TTL / expiry rationale ทำไมต้องมี TTL semantics ชัด

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:

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.

Audit sink boundary rationale ทำไมต้องมี audit boundary ก่อน

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 read access control, hash-chain signing, and the real producer wait for Phase 2c.

A-owned vs B-owned boundary เส้นแบ่ง ownership

File / routeOwnerState in Phase 2b
docs/runtime/admin-control-plane-service/phase_2b_contract.jsonAnew · Phase 2b contract
docs/runtime/admin-control-plane-service/approval_store_schema.jsonAnew · JSON-Schema draft-07
docs/runtime/admin-control-plane-service/approval_examples.jsonAnew · 8 seed rows
docs/runtime/admin-control-plane-service/audit_sink_contract.jsonAnew · audit sink boundary
docs/runtime/admin-control-plane-service/audit_event_examples.jsonAnew · 5 grounded examples
docs/runtime/admin-control-plane-service/app.pyAextended append-first · 4 new endpoints + Bearer resolver + store loader
docs/runtime/admin-control-plane-service/policy_engine.pyAextended append-first · 2 additive helpers · existing 24/24 functions unchanged
docs/runtime/admin-control-plane-service/phase-2b.htmlAnew · runtime debug surface
docs/runtime/admin-control-plane-service/policy_contract.jsonAunchanged · Phase 2a contract stable
docs/runtime/admin-control-plane/*.jsonAPhase 1 models unchanged · consumed read-only
docs/runtime/feature-flags-service/phase_2b_contract.jsonAsibling · pattern mirror · unchanged
docs/kb/data/approval_matrix.jsonBread-only · row-sensitive-override + dual_approval_rules reference
docs/kb/data/tenant_scope.jsonBread-only reference
docs/kb/data/publish_workflow.jsonBread-only reference
docs/kb/data/sla_model.jsonBread-only · sla_band ids referenced in rows

Sequence overview ลำดับการทำงาน

// Phase 2b request flow (admin-control-plane-service)

client → admin service
    headers: Authorization: Bearer <jwt>   (optional)
    headers: X-PTT-* (optional · legacy)

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}}

Route rationale เหตุผลของ path

Discoverability impact ผลกระทบต่อ discoverability

Deferred matrix รายการที่ยังไม่ทำ

ItemDeferred?Current stateWhy deferredNext logical phase
JWT verification (JWKS/IdP)yesdecode-only unless 3 precondsIdP + key rotation infraPhase 2b+ infra
Fallback dev X-PTT-* headersno (intentional)live for dev ergonomicsretire when verified JWT is on in proddeployment phase
Approval persistence backendyesfile-backed JSON · loaded at startupPostgres not provisionedPhase 2c
Approval TTL enforcement backendyescompute-at-read · no background sweeperscheduler infra not in scopePhase 2c
Audit sink delivery (Kafka)yespreview-only · stdout NDJSONKafka producer not wiredPhase 2b+ infra
Real signer workflow / mutation UIyesREAD-only · 8 seed rowsadmin CRUD + UI phasePhase 2c + 3
Real approval issuance flowyesnot implementeddepends on admin UIPhase 2c
WORM persistence + hash-chainyescontract documents itobject-lock bucket + signing key not wiredPhase 2b+ infra
Rate limitingyesnonedev-mode localhost bindingPhase 2b+ infra
Production deployment hardeningyeslocalhost onlydev-mode by designdeployment phase

Definition of Done · Phase 2b only เกณฑ์จบรอบนี้

Done when:

  • Phase 2a 24/24 example gate still passes after Phase 2b merge
  • Service runs with bash run.sh on port 8090 with no extra install beyond the existing requirements
  • Bearer token (even unsigned HS256) populates claims in unverified mode with warnings: ['auth_not_verified'] surfaced
  • Approval store loads 8 rows; validation endpoint returns correct TTL/expired/valid/reasons for each of: signed_full valid · signed_partial (dual · invalid) · expired · pending · withdrawn · rejected · not-found
  • sensitive.check.2b returns deny without approvals, allow with valid ref, deny + expired with expired ref, deny + dual_signers_required with partial dual
  • Audit preview returns envelope matching audit_event_model.base_shape for all 20 event_types · sink_status='deferred' on every response
  • Every deferred item is explicit in both the runtime debug page and this planning page
  • Index Portal finds all surfaces via the 10 target search terms
  • No B-owned file modified · no reinterpretation of B contracts
  • No new main-console / Ops Portal button

What Phase 2b is NOT สิ่งที่รอบนี้ไม่ใช่

Explicit disclaimers:

  • Not production-ready · not a complete admin plane
  • Not fully verified auth · signature verification gated on operator config
  • Not a real approval workflow · no create/sign/withdraw routes live
  • Not a fully persistent admin store · file-backed scaffold
  • Not a live audit sink · preview-only · Kafka/WORM deferred
  • Not a replacement for the Phase 2a service · additive · 24/24 preserved
Planning · Admin Control Plane Phase 2b · v1 · 2026-04-19 · A-owned
→ Runtime debug · phase_2b_contract.json · approval_examples.json · approval_store_schema.json · audit_sink_contract.json · audit_event_examples.json · ← Phase 1/2a planning · FF Phase 2b (sibling) · Cross-runtime integration · IA Governance