{
  "schema_version": "1.0",
  "baseline": "A-feature-flags-phase-2b-plus",
  "phase": "2b+",
  "updated_at": "2026-04-19",
  "owner": "session_a",
  "purpose_th": "เอกสาร contract สำหรับ FF Service Phase 2b+ · เน้น hardening ต่อจาก Phase 2b · ครอบคลุม verification modes · persistence backend · cache invalidation · policy-aware relation · rollout visibility · honest_limits",
  "purpose_en": "Contract for FF Service Phase 2b+ — hardening pass that follows Phase 2b. Covers verification modes, persistence backend, cache invalidation, policy-aware relation, rollout visibility, and honest limits. All additive; no Phase 2a or 2b surfaces altered.",
  "delta_from_2b": {
    "what_2b_shipped": [
      "http_contract.json · override_store_schema.json · override_examples.json",
      "phase_2b_contract.json · phase-2b.html",
      "app.py with /v1/flags/evaluate · /v1/flags/override/* · /v1/flags/sensitive/check",
      "File-backed tenant + user overrides · precedence (user > tenant > default)"
    ],
    "what_2b_plus_adds_in_this_contract": [
      "Formal verification-mode matrix (verified / decode-only / dev-fallback)",
      "Explicit persistence-backend current-state + planned-state note",
      "Cache-invalidation shape (channel · key-prefix · TTL · current-impl-note)",
      "Policy-aware sensitive gating cross-reference to Admin 2b sensitive.check.2b",
      "Rollout-visibility pointers to Batch-1 runtime surfaces",
      "Deferred matrix explicitly naming what is NOT done in 2b+"
    ],
    "what_2b_plus_does_not_change": [
      "http_contract.json wire schema (additive fields only when added, never removed)",
      "override_store_schema.json (unchanged — file-backed semantics preserved)",
      "Existing /v1/flags/evaluate decision logic (still decision-only, still parity-locked with evaluator.py)"
    ]
  },
  "verification_modes": {
    "resolution_order": [
      "Authorization: Bearer <token>",
      "X-PTT-User-Id / X-PTT-Tenant-Id / X-PTT-Role (dev headers · explicit)",
      "Request body user/tenant (POST only)",
      "Query string user=/tenant= (GET · URL-readable audit trail)",
      "Defaults (anonymous)"
    ],
    "modes": [
      {
        "mode": "verified",
        "condition": "Bearer JWT with valid signature against JWKS · aud=pty-feature-flags · iss match",
        "current_state": "not wired · planned for Phase 2c IdP integration",
        "claim_expectation": { "sub": "user_id", "tenant_id": "tenant_id", "roles": "array", "iat": "issued_at", "exp": "expiry_unix" },
        "on_success": "auth_source=jwt · warnings=[] · full precedence applies",
        "on_failure": "fall through to decode-only (2b+ behaviour) or reject (production behaviour, not enabled in dev)"
      },
      {
        "mode": "decode-only",
        "condition": "Bearer JWT parses but signature cannot be verified (no JWKS · or unverifiable in current runtime)",
        "current_state": "shipping in 2b+ runtime (warning surfaced)",
        "on_success": "auth_source=jwt_unverified · warnings=['auth_not_verified'] · claims consumed but caller must treat as advisory",
        "on_failure": "if decode also fails → fall through to dev-fallback"
      },
      {
        "mode": "dev-fallback",
        "condition": "No Bearer · dev X-PTT-* headers OR body/query provide user/tenant",
        "current_state": "shipping in 2b+ runtime (explicit dev-only label)",
        "on_success": "auth_source=dev_headers|query|body|mixed · warnings=['dev_mode'] · NEVER enabled in production",
        "on_failure": "auth_source=none · defaults apply · anonymous evaluation path"
      }
    ],
    "honest_note": "Verified mode path is documented but not wired in this commit. 2b+ hardens by making the MODE MATRIX explicit and by surfacing auth_source/warnings in every response so consumers can refuse unverified answers when policy demands it."
  },
  "persistence_backend": {
    "current_state": "in-memory cache of file-backed JSON · read on startup · written back to the same JSON files on mutation (dev only)",
    "files_backing": {
      "flag_registry": "docs/runtime/feature-flags/registry.json",
      "tenant_overrides": "docs/runtime/feature-flags-service/override_examples.json (read) · dev writes into the SAME file path in-place (local only)",
      "user_overrides": "same file · keyed by user_id"
    },
    "precedence": [
      "user override (if present and not expired)",
      "tenant override (if present and not expired)",
      "flag default (from registry)"
    ],
    "planned_backend": {
      "primary": "PostgreSQL (A-owned) · tables (flag_registry, tenant_override, user_override) · tenant_id indexed",
      "secondary_cache": "Redis · key-prefix ptt:ff: · TTL see cache_invalidation_contract.json",
      "current_gap": "No DB connection code in app.py · no Redis client · no write-ahead log",
      "migration_plan": "Phase 2c will introduce alembic migrations + parity tests against the JSON-backed baseline before cutover"
    },
    "honest_note": "2b+ documents the backend shape but continues to ship on file-backed storage. Any consumer reading this file to plan write paths must coordinate with Admin 2b audit sink first (approval for schema changes)."
  },
  "cache_invalidation": {
    "channel": "ptt.ff.invalidate (pub/sub · planned)",
    "key_prefixes": {
      "flag": "ptt:ff:flag:<flag_id>",
      "tenant_override": "ptt:ff:override:tenant:<tenant_id>:<flag_id>",
      "user_override": "ptt:ff:override:user:<user_id>:<flag_id>",
      "evaluated": "ptt:ff:eval:<user_id>:<tenant_id>:<flag_id>"
    },
    "default_ttl_seconds": {
      "flag_registry": 300,
      "override": 60,
      "evaluated": 30
    },
    "invalidation_triggers": [
      "override mutation → invalidate tenant_override + user_override prefixes for affected flag",
      "registry reload → invalidate flag + evaluated prefixes (broad)",
      "TTL expiry → passive invalidation on key lookup"
    ],
    "current_state": "not wired · no Redis client in app.py · invalidation logic documented in runtime debug page only",
    "honest_note": "2b+ documents invalidation shape so Phase 2c can wire Redis without redesigning semantics. Consumers should assume NO caching in 2b+ runtime (every evaluate call re-reads file-backed JSON)."
  },
  "policy_aware_sensitive_gating": {
    "relation_to_admin_2b": {
      "endpoint": "Admin 2b · POST /api/policy/sensitive.check.2b",
      "when_ff_consults": "When flag.registry entry has sensitive_flag=true OR requires_approval=true",
      "consult_flow": [
        "1. FF evaluate receives request",
        "2. FF resolves flag + overrides",
        "3. If flag.requires_approval=true → FF calls Admin 2b sensitive.check.2b with {target_type='feature_flag', target_id=flag_id, matrix_row=flag.approval_matrix_row}",
        "4. Admin 2b returns {decision, ttl_state, approval_ref?}",
        "5. FF applies: allow/deny/allow_with_warning based on Admin 2b decision",
        "6. FF response includes approval_ref + ttl_state in meta block"
      ],
      "current_state": "modeled in this contract · actual HTTP call from FF → Admin not wired in app.py",
      "fallback_if_admin_unreachable": "deny (fail-safe) · warning=['admin_unreachable'] · caller must not cache"
    },
    "impact_on_response": {
      "allow": "evaluation decision proceeds with approval_ref in meta",
      "deny": "evaluation returns denied=true · reason=requires_approval OR approval_expired · audit.boundary event emitted",
      "not_verified_approval": "evaluation returns denied=true · reason=auth_not_verified · caller must request fresh JWT"
    }
  },
  "rollout_visibility": {
    "batch_1_runtimes": [
      { "runtime": "/runtime/wizard/", "flag_candidate": "ff.wizard.interactive_draft", "current_gate": "none · scaffold visible to all" },
      { "runtime": "/runtime/enterprise-upload/", "flag_candidate": "ff.enterprise_upload.preview", "current_gate": "none · scaffold visible to all" },
      { "runtime": "/runtime/intake-workspace/", "flag_candidate": "ff.intake_workspace.preview", "current_gate": "none · scaffold visible to all" },
      { "runtime": "/runtime/generated-assets/", "flag_candidate": "ff.generated_assets.local_notes", "current_gate": "none · scaffold visible to all" },
      { "runtime": "/runtime/daily-queue/", "flag_candidate": "ff.daily_queue.simulation", "current_gate": "none · simulation visible to all" }
    ],
    "not_wired": [
      "Transition API (flip / rollback writes) — deferred to 2c",
      "Tenant-scoped cohort rollout — deferred to 2c",
      "Phased rollout percentages — deferred to 2c"
    ],
    "current_action_allowed": "Runtime pages MAY render a rollout badge reading the FLAG_CANDIDATE line + a link to this contract. They MUST NOT call a transition endpoint — there is none."
  },
  "error_modes": [
    { "code": "auth_not_verified", "http": 200, "meaning": "JWT decode-only mode · caller should refuse for sensitive calls" },
    { "code": "admin_unreachable", "http": 503, "meaning": "sensitive flag gating failed because Admin 2b service is down" },
    { "code": "approval_expired", "http": 200, "meaning": "flag gated and approval past TTL" },
    { "code": "dev_mode_rejected", "http": 401, "meaning": "production config set but dev fallback attempted" },
    { "code": "cache_stale", "http": 200, "meaning": "response served from cache that is past TTL (should not happen in 2b+ · flagged for future Redis integration)" }
  ],
  "deferred_matrix": [
    { "item": "JWT verified-mode wiring", "deferred": true, "current_state": "documented shape · no JWKS fetch", "next_phase": "2c IdP integration" },
    { "item": "RDBMS persistence", "deferred": true, "current_state": "file-backed JSON", "next_phase": "2c migration + parity test" },
    { "item": "Redis cache", "deferred": true, "current_state": "no client · no channel", "next_phase": "2c pub/sub wiring" },
    { "item": "Transition API / flip / rollback", "deferred": true, "current_state": "no endpoint", "next_phase": "2c after audit sink delivery" },
    { "item": "Tenant cohort rollout", "deferred": true, "current_state": "descriptive only", "next_phase": "2c rollout plan after tenant_scope publish_workflow maturity" },
    { "item": "Phased-rollout percentages", "deferred": true, "current_state": "none", "next_phase": "2c" },
    { "item": "Rate limiting", "deferred": true, "current_state": "none · relies on upstream gateway", "next_phase": "2c" },
    { "item": "Production deployment hardening", "deferred": true, "current_state": "run.sh local dev only", "next_phase": "future · requires platform decision" }
  ],
  "pair_with": {
    "planning": "docs/planning/feature-flags-phase-2b-plus.html",
    "runtime_debug": "docs/runtime/feature-flags-service/phase-2b-plus.html",
    "cache_contract": "docs/runtime/feature-flags-service/cache_invalidation_contract.json",
    "jwt_notes": "docs/runtime/feature-flags-service/jwt_verification_notes.json",
    "persistence_notes": "docs/runtime/feature-flags-service/persistence_backend_notes.json",
    "admin_2b_contract": "docs/runtime/admin-control-plane-service/phase_2b_contract.json"
  }
}
