{
  "schema_version": "1.0",
  "baseline": "A-runtime-admin-phase-2b",
  "phase": "Phase 2b",
  "updated_at": "2026-04-19",
  "owner": "session_a",
  "honest_note": "Phase 2b adds four capabilities on top of Phase 2a decision-only policy service: (1) Bearer JWT claim resolver (verified when PyJWT+POLICY_JWT_VERIFY+key all present, decode-only otherwise), (2) FILE-backed approval store loaded at startup (schema-validated rows grounded in approval_queue_model.queue_shape.item_fields), (3) TTL/expiry-aware approval validation (new endpoint computes ttl_remaining, expired, signed_partial vs signed_full, required_signer_count diff), (4) audit sink BOUNDARY (contract + preview endpoint that renders the event envelope grounded in audit_event_model.base_shape WITHOUT delivering to Kafka/WORM). Phase 2a endpoints are UNCHANGED — `verify_examples.py` still prints 24/24 after the Phase 2b merge. No CRUD writes · no signer workflow · no JWKS rotation · no WORM persistence · no Kafka producer · no admin UI in this phase.",

  "contract_refs": [
    "docs/runtime/admin-control-plane-service/policy_contract.json (Phase 2a · unchanged)",
    "docs/runtime/admin-control-plane/role_registry.json (Phase 1 · 12 roles · jwt_claim_shape)",
    "docs/runtime/admin-control-plane/access_policy.json (Phase 1 · precedence rules prec-1..prec-8)",
    "docs/runtime/admin-control-plane/approval_queue_model.json (Phase 1 · queue_shape · 10 queue_states · expiration_rules)",
    "docs/runtime/admin-control-plane/audit_event_model.json (Phase 1 · base_shape · 20 event_types · integrity_model hash_chain)",
    "docs/runtime/admin-control-plane-service/approval_store_schema.json (this commit · JSON-Schema for rows)",
    "docs/runtime/admin-control-plane-service/approval_examples.json (this commit · 8 seed rows)",
    "docs/runtime/admin-control-plane-service/audit_sink_contract.json (this commit · sink boundary)",
    "docs/runtime/admin-control-plane-service/audit_event_examples.json (this commit · 5 grounded events)",
    "docs/kb/data/approval_matrix.json (B-owned · read-only · row ids + dual_approval_rules)",
    "docs/runtime/feature-flags-service/phase_2b_contract.json (sibling · FF 2b pattern mirror)"
  ],

  "purpose_th": "ยกระดับ Admin service จาก decision-only ไปสู่ scaffold ที่มี store-backing + TTL + audit boundary โดยยังไม่เขียนจริง",
  "purpose_en": "Level up Admin service from decision-only to a scaffold with store backing + TTL + audit boundary, without any real writes",

  "delta_from_phase_2a": {
    "auth_context": {
      "before": "X-PTT-Actor-Role / X-PTT-Actor-User-Id / X-PTT-Actor-Tenant-Id / X-PTT-Target-Tenant-Id / X-PTT-Target-User-Id / X-PTT-Approval-Refs headers · query params · request body context",
      "after": "Authorization: Bearer <jwt> (verified when 3 preconditions all met · decode-only otherwise) · X-PTT-* remain as fallback · response service.auth_source enum in {jwt, jwt_unverified, dev_headers, query, body, mixed, none}"
    },
    "approval_handling": {
      "before": "policy_engine._active_approval() trusts any non-empty approval_ref string — NO TTL check · NO state check · NO signer count check",
      "after": "new endpoint POST /api/policy/approvals/validate consults file-backed store · returns {valid, approval_id, state, required_signer_count, current_signer_count, ttl_remaining_seconds, expired, sensitive_flag, matrix_row, reasons} · sensitive.check/access.check endpoints UNCHANGED (24/24 preserved) · new POST /api/policy/sensitive.check.2b opt-in endpoint that DOES consult the store"
    },
    "audit_boundary": {
      "before": "stdout JSON log per request · no structured event envelope",
      "after": "new POST /api/policy/audit/preview returns an event shape grounded in audit_event_model.base_shape · sink_status='deferred' (no Kafka delivery) · observability.preview_emits_to='stdout' only · integrity_model.hash_chain documented but not wired"
    },
    "error_vocabulary": {
      "added": [
        "auth_not_verified (warn · JWT decoded without signature verification)",
        "approval_store_unavailable (warn · file missing or parse failed)",
        "approval_not_found (404 · approval_id absent from store)",
        "approval_expired (200 · data.expired=true · reasons cite prec-8)",
        "approval_state_invalid (200 · data.state rejects consumption · e.g. withdrawn/rejected)"
      ]
    }
  },

  "auth_context_resolution": {
    "resolution_order": [
      "1. Authorization: Bearer <token> — parsed (decoded unconditionally · VERIFIED only when 3 preconditions all satisfied below)",
      "2. X-PTT-Actor-Role / X-PTT-Actor-User-Id / X-PTT-Actor-Tenant-Id / X-PTT-Target-Tenant-Id / X-PTT-Target-User-Id / X-PTT-Approval-Refs — fill claims the bearer did not provide",
      "3. Request body context — equal or lower priority than header fallback",
      "4. Query params — lowest priority",
      "5. Defaults: actor_role is required (400 invalid_request if still missing after all sources)"
    ],
    "verification_conditions_all_required_for_real_verification": [
      "PyJWT library installed (optional dep · commented in requirements.txt)",
      "Env POLICY_JWT_VERIFY=true",
      "Env POLICY_JWT_PUBLIC_KEY (PEM) or POLICY_JWKS_URL set",
      "Token has valid signature + exp + audience (aud defaults to 'pty-admin-policy')"
    ],
    "honest_statement_when_not_verified": "service.auth_source='jwt_unverified' + response.service.warnings += 'auth_not_verified'",
    "verification_modes": {
      "verified":        "all 3 preconditions met AND signature valid AND exp in future · auth_source='jwt'",
      "decode_only":     "bearer present but missing precondition OR signature invalid · auth_source='jwt_unverified' + warning",
      "header_fallback": "no bearer · context from X-PTT-* · auth_source='dev_headers'",
      "query_fallback":  "no bearer no headers · context from query · auth_source='query'",
      "body_fallback":   "no bearer no headers · context from request body · auth_source='body'",
      "mixed":           "multiple sources contributed claims · auth_source='mixed' · per-claim source tracked in trace",
      "none":            "nothing resolved · user_id/actor_role still missing · HTTP 400 invalid_request"
    }
  },

  "jwt_claim_expectation": {
    "algorithm_planned": "RS256 (HS256 acceptable for local tests)",
    "audience_default":  "pty-admin-policy",
    "claims": {
      "sub":        {"required": true,  "maps_to": "actor_user_id"},
      "role":       {"required": true,  "maps_to": "actor_role",    "enum_source": "role_registry.roles[].key"},
      "tenant_id":  {"required": false, "maps_to": "actor_tenant_id"},
      "tier":       {"required": true,  "enum": ["anonymous","member","gold","platinum","staff","admin"]},
      "tenant_roles":{"required": false, "type": "array<string>"},
      "assist_ctx": {"required": false, "shape_ref": "assist_model.modes.assist.consent_record_fields"},
      "view_as_ctx":{"required": false, "shape_ref": "assist_model.modes.view_as_tenant.ui_indicator"},
      "approval_refs":{"required": false, "type":"array<string>", "description":"JWT-carried approval hints · server still consults store for validity"},
      "exp":        {"required": true},
      "iat":        {"required": true},
      "iss":        {"required": false},
      "aud":        {"required": false}
    },
    "jwks_url_env":    "POLICY_JWKS_URL",
    "public_key_env":  "POLICY_JWT_PUBLIC_KEY",
    "rotation_plan_deferred": "JWKS cache + rotation is Phase 2b+ infra · env vars reserved so scaffold shape stays stable"
  },

  "approval_store_shape": {
    "file_location": "docs/runtime/admin-control-plane-service/approval_examples.json (default · overridable via env ADMIN_APPROVAL_STORE_PATH)",
    "schema_ref":    "docs/runtime/admin-control-plane-service/approval_store_schema.json",
    "load_policy":   "read once at service startup · no hot reload (deferred to 2b+ alongside Redis)",
    "row_fields_summary": [
      "approval_id (APP-YYMMDD-XXXX · stable)",
      "matrix_row (FK to approval_matrix.matrix_rows[].id)",
      "target_type (case|asset|flag_transition|tenant_publish|wizard_bundle|canonical_promote)",
      "target_id",
      "submitted_by + submitter_role",
      "required_signer_roles[] + required_signer_count (1 or 2 for dual)",
      "current_signers[{user_id,role,decision,signed_at,rationale}]",
      "sensitive_flag (bool)",
      "state (pending|in_review|signed_partial|signed_full|rejected|sent_back|withdrawn|expired)",
      "sla_band (from sla_model)",
      "sla_due_at (ISO) · doubles as TTL deadline",
      "opened_at · closed_at (nullable)",
      "audit_event_refs[] (ids into audit store · display-only in this phase)"
    ],
    "row_shape_authority": "Phase 1 approval_queue_model.queue_shape.item_fields is canonical · this schema MIRRORS it 1:1 · any divergence is a bug",
    "write_policy": "READ-ONLY in Phase 2b · no create/sign/withdraw endpoints"
  },

  "ttl_semantics": {
    "source_of_truth_field": "item.sla_due_at",
    "ttl_remaining_formula": "max(0, floor((sla_due_at - now_iso).total_seconds))",
    "expired_definition": "sla_due_at < now_iso OR state == 'expired'",
    "validity_rule": "valid := state in ('signed_partial','signed_full') AND NOT expired AND current_signer_count >= required_signer_count_or_partial_rule",
    "partial_rule": "signed_partial counts as PROVISIONAL valid for non-dual flags · for dual-approval rows (required_signer_count=2) signed_partial returns valid=false with reason 'dual_signers_required'",
    "expired_consumption_rule": "expired approvals MUST be treated as if absent · engine returns data.expired=true · reasons cite prec-8 (fail-safe default) · cannot auto-revive (per approval_queue_model.expiration_rules)",
    "clock_source": "request.now_iso if provided (tests) · else server UTC clock",
    "honest_note": "No background sweeper · state='expired' in store is only set manually or by the Phase 2c transition API · Phase 2b computes expired at READ time from sla_due_at comparison"
  },

  "audit_sink_shape": {
    "contract_file": "docs/runtime/admin-control-plane-service/audit_sink_contract.json",
    "envelope_fields": "per audit_event_model.base_shape (event_id, event_type, event_category, timestamp, actor, subject, action, approval_refs, mask_level_at_event, session_ref)",
    "delivery_mode": "local-only",
    "sink_status": "deferred",
    "what_runs_now": "POST /api/policy/audit/preview renders the event envelope for an (actor, action, subject) tuple · returns the shape WITHOUT delivering · stdout logs the envelope JSON · nothing touches Kafka",
    "what_is_deferred": [
      "Kafka producer to ptt.audit.trail (7-year retention per CLAUDE.md)",
      "WORM object-lock persistence",
      "hash-chain integrity signing (integrity_model.hash_chain)",
      "replay/query API"
    ],
    "preview_endpoint_is_a_CONTRACT_CHECK": "Not a logging pipeline. It is a way for downstream runtimes to see the exact shape their eventual audit emission will need."
  },

  "read_paths": [
    {"method":"GET",  "path":"/api/policy/access/check",              "status":"unchanged from 2a",  "note":"accepts Bearer · auth_source field on response"},
    {"method":"POST", "path":"/api/policy/access/check/batch",        "status":"unchanged from 2a"},
    {"method":"POST", "path":"/api/policy/mask/resolve",              "status":"unchanged from 2a"},
    {"method":"POST", "path":"/api/policy/assist/validate",           "status":"unchanged from 2a"},
    {"method":"POST", "path":"/api/policy/view-as/validate",          "status":"unchanged from 2a"},
    {"method":"POST", "path":"/api/policy/sensitive/check",           "status":"unchanged from 2a · still trusts approval_refs at face value"},
    {"method":"GET",  "path":"/api/policy/health",                    "status":"unchanged from 2a"},
    {"method":"GET",  "path":"/api/policy/approvals/{approval_id}",   "status":"NEW (Phase 2b)",   "note":"read-only · returns a single approval row with computed ttl_remaining + expired + valid"},
    {"method":"POST", "path":"/api/policy/approvals/validate",        "status":"NEW (Phase 2b)",   "note":"validates one or more approval_ids · TTL/state aware · returns per-ref report"},
    {"method":"POST", "path":"/api/policy/sensitive.check.2b",        "status":"NEW (Phase 2b)",   "note":"store-aware sensitive.check · consults TTL · does NOT replace Phase 2a /api/policy/sensitive/check"},
    {"method":"POST", "path":"/api/policy/audit/preview",             "status":"NEW (Phase 2b)",   "note":"renders an audit envelope for (actor,action,subject) WITHOUT delivering · sink_status='deferred'"},
    {"method":"GET",  "path":"/phase-2b/health",                      "status":"NEW (Phase 2b)",   "note":"extended health · pyjwt/verify status · approval_store status · audit sink status"}
  ],

  "write_paths": {
    "status": "NONE live in Phase 2b",
    "reason": "Mutation (approval creation, sign, withdraw, transition) is deferred to Phase 2c. This phase stays READ-only to preserve the decision-service safety profile.",
    "planned_for_2c": [
      "POST /api/policy/approvals              (create new approval)",
      "POST /api/policy/approvals/{id}/sign    (signer action · moves state)",
      "POST /api/policy/approvals/{id}/withdraw",
      "POST /api/policy/approvals/{id}/reject"
    ]
  },

  "error_modes": {
    "auth_not_verified": {
      "http": 200, "severity": "warn",
      "how_surfaced": "service.auth_source='jwt_unverified' + service.warnings includes 'auth_not_verified'",
      "when": "Bearer provided but verification preconditions not all met (PyJWT missing OR POLICY_JWT_VERIFY!=true OR key missing OR signature invalid)",
      "operator_action": "dev only · do not rely on identity claims for prod decisions"
    },
    "approval_store_unavailable": {
      "http": 200, "severity": "warn",
      "how_surfaced": "service.warnings includes 'approval_store_unavailable' · /phase-2b/health reports loaded=false",
      "when": "ADMIN_APPROVAL_STORE_PATH file missing or JSON parse fails at startup",
      "operator_action": "check file path · restart service after fix"
    },
    "approval_not_found": {
      "http": 404,
      "how_surfaced": "envelope.error.code='approval_not_found'",
      "when": "approval_id requested via GET /api/policy/approvals/{id} not present in loaded store",
      "operator_action": "verify approval_id · fix request"
    },
    "approval_expired": {
      "http": 200,
      "how_surfaced": "data.expired=true · data.valid=false · reasons include 'approval_expired'",
      "when": "sla_due_at < now_iso OR state == 'expired'",
      "operator_action": "resubmit a new approval_id (Phase 2c · not live)"
    },
    "approval_state_invalid": {
      "http": 200,
      "how_surfaced": "data.valid=false · reasons include 'approval_state_invalid:{state}'",
      "when": "state in ('withdrawn','rejected','sent_back') — cannot be consumed as valid",
      "operator_action": "approval must be rewrought"
    },
    "dual_signers_required": {
      "http": 200,
      "how_surfaced": "data.valid=false · reasons include 'dual_signers_required' · data.current_signer_count < required_signer_count",
      "when": "required_signer_count >= 2 AND current_signer_count < required",
      "operator_action": "wait for second signer or obtain signed_full state"
    },
    "invalid_request": {"http": 400, "status": "unchanged from 2a"},
    "models_unavailable": {"http": 503, "status": "unchanged from 2a"}
  },

  "observability": {
    "phase_2b_reality": "stdout JSON log per request · event enum gains 'approval_read', 'approval_validate', 'sensitive_check_2b', 'audit_preview'",
    "fields_added_to_log": ["auth_source", "approval_id", "approval_state", "ttl_remaining_seconds", "expired"],
    "audit_preview_stdout": "Preview endpoint also emits the envelope as a stdout log line · honest duplication so operators can tail logs and see what would be delivered",
    "metrics_deferred": ["prometheus export · OTel tracing · policy_decision_total{operation,decision} · approval_validity_total{valid,expired}"]
  },

  "non_goals_phase_2b": [
    "Real JWT signature verification via JWKS/IdP at runtime (contract reserves env vars · implementation depends on PyJWT install + infra)",
    "Postgres-backed approval persistence (scaffold is file-backed · rows are read-only)",
    "Approval CRUD / signer workflow (create, sign, withdraw, transition · deferred to Phase 2c)",
    "Background TTL sweeper that moves rows to state='expired' at sla_due_at (Phase 2c · scaffolded here via compute-at-read)",
    "Kafka ptt.audit.trail producer / WORM object-lock persistence / hash-chain integrity signing",
    "Real admin mutation UI / Kanban / signer kanban (Phase 3)",
    "Rate limiting / quota enforcement",
    "JWKS cache + rotation",
    "Hot reload of registry or approval store on SIGHUP (deferred)",
    "Cross-service calls from inside this service beyond what Phase 2a already had"
  ],

  "deferred_matrix": [
    {"item": "JWT verification (JWKS/IdP)",           "deferred": true,  "current": "decode-only · verification gated by 3 env/install preconditions", "why": "depends on IdP integration + key rotation", "next_phase": "Phase 2b+ infra"},
    {"item": "fallback dev X-PTT-* headers",          "deferred": false, "current": "LIVE by design for local dev", "why": "ergonomics",    "next_phase": "retire in prod deployment"},
    {"item": "approval persistence backend",          "deferred": true,  "current": "file-backed JSON · loaded at startup · no writes", "why": "Postgres not provisioned", "next_phase": "Phase 2c"},
    {"item": "approval TTL enforcement backend",      "deferred": true,  "current": "compute-at-read only · no background sweeper flipping state", "why": "scheduler infra not in scope", "next_phase": "Phase 2c"},
    {"item": "audit sink delivery (Kafka)",           "deferred": true,  "current": "preview endpoint returns envelope · stdout only", "why": "Kafka producer not wired", "next_phase": "Phase 2b+ infra"},
    {"item": "WORM persistence + hash-chain",         "deferred": true,  "current": "contract documents it · no implementation", "why": "object-lock bucket + signing key not wired", "next_phase": "Phase 2b+ infra"},
    {"item": "real signer workflow (sign/withdraw)",  "deferred": true,  "current": "READ-only · store rows are seed data", "why": "belongs to admin CRUD + UI phase", "next_phase": "Phase 2c + 3"},
    {"item": "real approval issuance flow",           "deferred": true,  "current": "not implemented", "why": "depends on admin UI", "next_phase": "Phase 2c"},
    {"item": "admin mutation UI / Kanban",            "deferred": true,  "current": "none", "why": "UI phase", "next_phase": "Admin Phase 3"},
    {"item": "rate limiting",                         "deferred": true,  "current": "none", "why": "dev-mode localhost binding", "next_phase": "Phase 2b+ infra"},
    {"item": "production deployment hardening",       "deferred": true,  "current": "localhost only", "why": "dev-mode by design", "next_phase": "deployment phase"}
  ],

  "phase_2b_deliverables": [
    "phase_2b_contract.json (this file)",
    "approval_store_schema.json",
    "approval_examples.json (8 rows · valid · expired · dual-partial · signed_full · pending · withdrawn · rejected · sensitive-approved)",
    "audit_sink_contract.json",
    "audit_event_examples.json (5 grounded events · 5 different event_types · 5 different categories)",
    "app.py extended append-first · 4 new endpoints · Bearer resolver · store loader · ttl helpers",
    "policy_engine.py extended append-first · ONE new function _active_approval_with_store · 24/24 parity preserved on existing functions",
    "phase-2b.html (runtime debug · JWT · store · TTL · audit · 5 use cases)",
    "docs/planning/admin-control-plane-phase-2b.html (planning rationale · DoD · deferred)",
    "Catalog entries + cross-links + SYNC append"
  ]
}
