{
  "schema_version": "1.0",
  "baseline": "A-runtime-admin-phase-2a",
  "phase": "Phase 2a",
  "updated_at": "2026-04-19",
  "owner": "session_a",
  "honest_note": "Policy engine contract + runnable FastAPI scaffold are LIVE for local/dev only. No IdP · no JWT verification · no Redis cache · no WORM audit sink · no approval store backing. Context comes from X-PTT-* headers or request body. This phase is a DECISION service — it never executes actions, never writes audit events, never mutates state. It only answers 'what should happen?' given a request context.",

  "contract_refs": [
    "docs/runtime/admin-control-plane/role_registry.json (12 roles · 4 categories)",
    "docs/runtime/admin-control-plane/access_policy.json (6 mask rows · 8 precedence rules)",
    "docs/runtime/admin-control-plane/assist_model.json (view_as + assist · 7 forbidden patterns)",
    "docs/runtime/admin-control-plane/approval_queue_model.json (10 states · 5 signer actions)",
    "docs/runtime/admin-control-plane/audit_event_model.json (20 event types · hash-chain)",
    "docs/runtime/admin-control-plane/schema.json",
    "docs/kb/data/approval_matrix.json (B-owned · read-only)"
  ],

  "purpose_th": "สัญญา service สำหรับตัดสินใจเรื่อง access · masking · assist · view-as · sensitive · deterministic · trace-able",
  "purpose_en": "Service contract for access / masking / assist / view-as / sensitive decisions · deterministic · fully traced",

  "transport": {
    "protocol": "HTTP/1.1 · JSON · UTF-8",
    "default_dev_port": 8090,
    "default_dev_base_url": "http://127.0.0.1:8090",
    "service_entrypoint": "docs/runtime/admin-control-plane-service/app.py (FastAPI)",
    "start_command": "bash docs/runtime/admin-control-plane-service/run.sh",
    "startup_behavior": "Loads 5 Phase 1 JSON models once at startup · refuses to serve if any model fails to parse",
    "cors_dev": "Access-Control-Allow-Origin: * (disable in prod)"
  },

  "service_identity": {
    "service_name": "admin-control-plane-service",
    "service_version": "2a-py-scaffold-0.1",
    "engine_version": "2a-py-policy-engine",
    "decision_determinism": "same (models, request, now_iso) → same (decision, reasons, trace)"
  },

  "operation_types": {
    "access.check": {
      "purpose_en": "Answer 'can this actor read/write this thing right now?' · returns allow | deny | mask",
      "side_effects": "none · decision only",
      "fail_safe": "deny on any missing or malformed context"
    },
    "mask.resolve": {
      "purpose_en": "For a field_category + actor context · return mask_level + mask_form",
      "side_effects": "none",
      "fail_safe": "masked"
    },
    "assist.validate": {
      "purpose_en": "Given assist_ctx · validate role eligibility · consent record · TTL · scope",
      "side_effects": "none",
      "fail_safe": "invalid"
    },
    "viewas.validate": {
      "purpose_en": "Given view_as_ctx · validate role eligibility · TTL · reject write-intent in read-only mode",
      "side_effects": "none",
      "fail_safe": "invalid"
    },
    "sensitive.check": {
      "purpose_en": "For a sensitive-surface action · list required approvers · check whether approval_ref + dual signers satisfy approval_matrix",
      "side_effects": "none",
      "fail_safe": "deny"
    }
  },

  "response_envelope": {
    "description_en": "Every response shares the same envelope. Decision payloads live under `data`. Errors under `error`.",
    "shape": {
      "ok": "boolean (true on 2xx)",
      "data": "any (nullable) · per-operation decision shape",
      "error": {
        "code": "enum: invalid_request | unknown_role | models_unavailable | internal_error",
        "message": "string",
        "hint": "string (optional)"
      },
      "service": {
        "service_version": "string",
        "engine_version": "string",
        "request_id": "uuid-v4 (echoed in X-Request-Id)"
      }
    }
  },

  "common_request_context": {
    "actor_role":         { "type": "string", "required": true, "description": "one of role_registry.roles[].key" },
    "actor_user_id":      { "type": "string", "required": true, "description": "stable id of the actor making the request" },
    "actor_tenant_id":    { "type": "string", "required": false, "description": "tenant_id embedded in actor context (null for platform roles)" },
    "target_tenant_id":   { "type": "string", "required": false, "description": "tenant whose data is being accessed · may differ from actor_tenant_id" },
    "target_user_id":     { "type": "string", "required": false, "description": "end-user whose record is being accessed · enables self-view check" },
    "assist_ctx":         { "type": "object", "required": false, "fields": ["consent_record_id","target_tenant_id","granted_at","expires_at","scope[]","case_ref"] },
    "view_as_ctx":        { "type": "object", "required": false, "fields": ["target_tenant_id","granted_at","expires_at"] },
    "approval_refs":      { "type": "array<string>", "required": false, "description": "approval_queue ids currently held by the actor · time-bounded" },
    "now_iso":            { "type": "string", "required": false, "description": "ISO-8601 override for TTL math · tests only · defaults to server clock" }
  },

  "endpoints": [
    {
      "id": "ep-access-check",
      "method": "GET",
      "path": "/api/policy/access/check",
      "operation": "access.check",
      "description_en": "Single access decision. Context from X-PTT-* headers or query params.",
      "query_params": {
        "actor_role":        "required",
        "actor_user_id":     "required",
        "actor_tenant_id":   "optional",
        "target_tenant_id":  "optional",
        "target_user_id":    "optional",
        "field_category":    "optional · see access_policy.masking_policy_rows[].field_category",
        "requested_action":  "enum · read | write | export · default read",
        "is_sensitive":      "boolean · default false",
        "approval_refs":     "comma-separated ids",
        "now_iso":           "optional · ISO-8601"
      },
      "response_data_shape": {
        "decision":       "enum · allow | mask | deny",
        "mask_level":     "enum · unmasked | masked | masked-category-only | denied (when deny)",
        "mask_form":      "string (optional · e.g. a***@***.com)",
        "reasons":        "array of reason objects · each { id, text, rule_ref }",
        "required_approvers": "array of role keys · when action is gated",
        "approval_matrix_row": "string (optional · when approval required)",
        "self_view":      "boolean · true if actor is the subject",
        "tenant_relation": "enum · own | cross · unknown | platform-global",
        "trace":          "array<string> · ordered step log"
      }
    },
    {
      "id": "ep-access-check-batch",
      "method": "POST",
      "path": "/api/policy/access/check/batch",
      "operation": "access.check",
      "description_en": "Batch access decisions · shared context + per-item overrides.",
      "request_body_shape": {
        "context": "common_request_context object (shared)",
        "items":   "array<{ field_category?, requested_action?, is_sensitive?, target_user_id? }>"
      },
      "response_data_shape": "array of same shape as /access/check"
    },
    {
      "id": "ep-mask-resolve",
      "method": "POST",
      "path": "/api/policy/mask/resolve",
      "operation": "mask.resolve",
      "description_en": "Resolve masking policy for a specific field for a specific actor/target context · returns mask_level + mask_form + reasons.",
      "request_body_shape": {
        "context": "common_request_context",
        "field_category": "string · one of access_policy.masking_policy_rows[].field_category",
        "target_user_id": "optional · enables self-view shortcut"
      },
      "response_data_shape": {
        "field_category":  "string",
        "mask_level":      "enum · unmasked | masked | masked-category-only | denied",
        "mask_form":       "string (sample of masked output)",
        "reasons":         "array",
        "ttl_remaining_seconds": "integer (when granted via approval)",
        "required_approvers": "array · when unmask is requestable",
        "precedence_rule_applied": "string · id from access_policy.precedence_rules"
      }
    },
    {
      "id": "ep-assist-validate",
      "method": "POST",
      "path": "/api/policy/assist/validate",
      "operation": "assist.validate",
      "description_en": "Validate an active assist session · checks role eligibility, consent record, TTL, scope, forbidden patterns.",
      "request_body_shape": {
        "context": "common_request_context (must include assist_ctx)",
        "proposed_action": "string (optional) · e.g. 'edit_profile', 'propose_approval', 'sign_approval'"
      },
      "response_data_shape": {
        "valid": "boolean",
        "reasons": "array",
        "ttl_remaining_seconds": "integer",
        "expires_at": "ISO-8601",
        "forbidden_pattern_hit": "string | null · names any matched pattern from assist_model.forbidden_patterns",
        "proposed_action_allowed": "boolean (when proposed_action supplied)"
      }
    },
    {
      "id": "ep-viewas-validate",
      "method": "POST",
      "path": "/api/policy/view-as/validate",
      "operation": "viewas.validate",
      "description_en": "Validate view-as-tenant session · always read-only · writes always rejected · banner required.",
      "request_body_shape": {
        "context": "common_request_context (must include view_as_ctx)",
        "proposed_action": "string (optional) · read | write | export"
      },
      "response_data_shape": {
        "valid": "boolean",
        "read_allowed": "boolean",
        "write_allowed": "constant false",
        "banner_required": "constant true",
        "ttl_remaining_seconds": "integer",
        "expires_at": "ISO-8601",
        "reasons": "array"
      }
    },
    {
      "id": "ep-sensitive-check",
      "method": "POST",
      "path": "/api/policy/sensitive/check",
      "operation": "sensitive.check",
      "description_en": "For a sensitive-surface field/action · list required dual-signers and check if current approval_refs satisfy them.",
      "request_body_shape": {
        "context": "common_request_context",
        "field_category": "string",
        "requested_action": "enum · read | write | export"
      },
      "response_data_shape": {
        "decision": "enum · allow | deny",
        "required_approvers": "array of role keys · per access_policy.masking_policy_rows[].sensitive_escalation",
        "approval_matrix_row": "string · typically row-sensitive-override",
        "current_approvals_satisfy": "boolean",
        "reasons": "array"
      }
    },
    {
      "id": "ep-health",
      "method": "GET",
      "path": "/api/policy/health",
      "operation": "health",
      "description_en": "Liveness + readiness · returns model-loaded status + counts + version.",
      "response_data_shape": {
        "status": "enum · ready | degraded",
        "models_loaded": "object · { role_registry:true, access_policy:true, assist_model:true, approval_queue_model:true, audit_event_model:true }",
        "counts": "object · { roles, mask_rows, precedence_rules, forbidden_patterns }",
        "uptime_seconds": "integer",
        "service_version": "string",
        "engine_version": "string"
      }
    }
  ],

  "auth_strategy": {
    "phase_2a_reality": "Dev-mode only. 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 OR same fields in request body / query. Bind to localhost. Never safe to expose publicly without IdP.",
    "dev_context_resolution_order": [
      "1. X-PTT-* headers (win on conflict)",
      "2. Query params (/access/check)",
      "3. Request body `context` object (POST endpoints)",
      "4. Defaults: actor_role='end_user', actor_tenant_id=null, now_iso=server clock"
    ],
    "phase_2b_plan": {
      "mechanism": "JWT bearer · claims shape per role_registry.jwt_claim_shape",
      "required_claims": ["sub","role","tier","tenant_id","assist_ctx?","view_as_ctx?","exp"],
      "header_fallback": "disabled when JWT middleware is active",
      "deferred_because": "Requires IdP + JWKS · binds to Feature Flags 2b override store timing"
    }
  },

  "decision_semantics": {
    "masked_by_default": "Every PII/sensitive field returns masked unless a precedence rule explicitly allows unmasked. Missing context → masked.",
    "deny_on_unknown_role": "If actor_role is not in role_registry.roles[] · decision=deny · reason unknown_role.",
    "self_view_exception": "actor_user_id === target_user_id → unmasked for own data (per precedence_rules.prec-3).",
    "tenant_sovereignty": "Own-tenant tenant_admin / tenant_dpo → unmasked on own-tenant PII (prec-4).",
    "cross_tenant_without_context": "If actor_tenant_id != target_tenant_id AND no assist_ctx AND no view_as_ctx → deny (or mask for platform roles). Platform roles get mask-only access unless approval_refs present.",
    "sensitive_escalation": "Fields flagged with sensitive_escalation=true in access_policy require DUAL signers per approval_matrix.row-sensitive-override before unmask (prec-6).",
    "producer_irreversible": "GPS / PII-hash rules at producer cannot be bypassed at access layer (prec-2). GPS coordinate unmask is always denied.",
    "approval_ttl": "Held approvals carry expires_at · engine treats expired refs as missing (prec-8).",
    "view_as_is_read_only": "Any proposed_action other than 'read' in view_as_ctx → valid=false.",
    "assist_write_scope": "Assist allows write only for actions in consent_record.scope[]. Approvals still route through tenant-admin.",
    "forbidden_patterns_enforced": "Silent impersonation · delegated approval · permanent TTL · cross-tenant during session · banner hiding · audit disable · tenant overriding admin log — all checked per assist_model.forbidden_patterns."
  },

  "trace_policy": {
    "every_response_includes_trace": true,
    "trace_entries_format": "[step_number] step_name: details",
    "trace_stable_across_calls": true,
    "pii_in_trace": "never · trace references ids and role names only"
  },

  "error_codes": {
    "invalid_request":     { "http": 400, "when": "missing required field · malformed JSON · unknown field_category" },
    "unknown_role":        { "http": 200, "note": "surfaced via data.decision=deny + reason 'unknown_role' · request itself is valid" },
    "models_unavailable":  { "http": 503, "when": "any of the 5 Phase 1 JSON models failed to parse at startup" },
    "internal_error":      { "http": 500, "when": "uncaught exception · should not happen given contract · alertable" }
  },

  "observability": {
    "phase_2a_reality": "stdout JSON log line per request · event=access_check | mask_resolve | assist_validate | viewas_validate | sensitive_check | health · includes request_id, decision, reasons_count, duration_ms. No WORM audit sink in this phase.",
    "future_plan": {
      "audit_sink": "every decision + every assist/view-as session → Kafka ptt.audit.trail (7yr retention per CLAUDE.md)",
      "metrics": ["policy_decision_total{operation,decision}", "policy_denied_total{reason}", "policy_mask_resolved_total{field_category,mask_level}"],
      "deferred_because": "Kafka infra + audit_event_model binding is Phase 2b/2c work"
    }
  },

  "non_goals_phase_2a": [
    "JWT / IdP / JWKS integration (Phase 2b)",
    "Real WORM audit sink (Kafka · ptt.audit.trail)",
    "Approval queue CRUD / signer actions (decisions only · no mutation)",
    "Admin Kanban flip UI (Phase 3)",
    "Feature flag runtime-based rule toggles (read access_policy directly for now)",
    "Cross-tenant write workflows",
    "Rate limiting · quota enforcement (Phase 2b+)",
    "Hot reload of model files · SIGHUP reparse (deferred)"
  ],

  "phase_2a_deliverables": [
    "policy_contract.json (this file)",
    "app.py (FastAPI scaffold · 7 endpoints: access · access/batch · mask · assist · viewas · sensitive · health)",
    "policy_engine.py (Python policy evaluator · consumes all 5 Phase 1 JSON models · deterministic · fully traced)",
    "policy_examples.json (canonical decision vectors · deny · mask · assist · viewas · sensitive · self-view)",
    "verify_examples.py (regression runner · every example must match expected decision)",
    "requirements.txt · run.sh · .gitignore",
    "service.html (Phase 2a debug page · composer · examples runner · contract viewer · model source links)",
    "README.md (overview · run · endpoints · phase gates · honest limits)"
  ]
}
