{
  "schema_version": "1.0",
  "baseline": "A-runtime-phase-2a-plus",
  "phase": "Phase 2a+",
  "updated_at": "2026-04-18",
  "owner": "session_a",
  "honest_note": "HTTP contract + runnable FastAPI scaffold are LIVE for local/dev only. No public deployment · no JWT · no Redis cache · no pubsub invalidation · no override stores · no transition API. Auth in this phase is a dev-mode header/query shim; production auth (JWT via IdP) is deferred to Phase 2b. Evaluator semantics are a Python port mirroring evaluator.js; parity is enforced by verify_parity.py against the Phase 2a example vectors (19/19).",

  "contract_refs": [
    "docs/runtime/feature-flags/evaluator_contract.json (Phase 2a · source contract)",
    "docs/runtime/feature-flags/evaluation_model.json (9-step order)",
    "docs/runtime/feature-flags/rollout_model.json (7-state machine)",
    "docs/runtime/feature-flags/registry.json (source of truth)",
    "docs/runtime/feature-flags/evaluator.js (reference JS implementation)",
    "docs/runtime/feature-flags/evaluator_examples.json (parity test vectors)"
  ],

  "purpose_th": "นิยามขอบเขต HTTP ของ evaluator service · endpoint · envelope · error · auth · dev-mode",
  "purpose_en": "Define the HTTP boundary of the evaluator service · endpoints · response envelope · error codes · auth strategy · dev-mode context",

  "transport": {
    "protocol": "HTTP/1.1 · JSON request+response bodies · UTF-8",
    "default_dev_port": 8080,
    "default_dev_base_url": "http://localhost:8080",
    "content_type_request": "application/json (POST) · query string (GET)",
    "content_type_response": "application/json",
    "cors_dev": "Access-Control-Allow-Origin: * · disabled in prod config",
    "service_entrypoint": "docs/runtime/feature-flags-service/app.py (FastAPI)",
    "start_command": "bash docs/runtime/feature-flags-service/run.sh",
    "startup_behavior": "Loads registry.json once at process start · rejects startup if registry fails to parse · no hot reload in this phase"
  },

  "service_identity": {
    "service_name": "feature-flags-service",
    "service_version": "2a_plus-py-scaffold-0.1",
    "evaluator_version": "2a_plus-py-port",
    "parity_ref": "evaluator.js#2a-js-module",
    "parity_check": "docs/runtime/feature-flags-service/verify_parity.py (must return 19/19 pass)"
  },

  "response_envelope": {
    "description_en": "All successful and most error responses share a common envelope. Raw evaluator payloads live under `data`. Error details (if any) under `error`.",
    "shape": {
      "ok": { "type": "boolean", "description": "true on 2xx · false on 4xx/5xx" },
      "data": { "type": "any", "nullable": true, "description": "evaluator result · batch array · registry snapshot" },
      "error": {
        "type": "object",
        "nullable": true,
        "fields": {
          "code": { "type": "string", "values": ["invalid_request", "unknown_flag", "registry_unavailable", "internal_error"] },
          "message": { "type": "string" },
          "hint": { "type": "string", "nullable": true }
        }
      },
      "service": {
        "type": "object",
        "fields": {
          "service_version": "string",
          "evaluator_version": "string",
          "request_id": "string (uuid v4 · echoed in X-Request-Id header)"
        }
      }
    }
  },

  "endpoints": [
    {
      "id": "ep-eval",
      "method": "GET",
      "path": "/api/flags/eval",
      "description_en": "Evaluate a single flag. Context is taken from query params (dev) or X-PTT-* headers (also dev). JWT-derived context is Phase 2b.",
      "query_params": {
        "key":       { "type": "string", "required": true,  "description": "flag_key to evaluate" },
        "tenant":    { "type": "string", "required": false, "description": "tenant_id · optional for platform-level flags" },
        "user":      { "type": "string", "required": false, "description": "user_id · required if header X-PTT-User-Id is absent" },
        "env":       { "type": "enum",   "required": false, "values": ["prod","staging","dev"], "description": "defaults to 'dev' if absent" },
        "tier":      { "type": "enum",   "required": false, "values": ["anonymous","member","gold","platinum","staff","admin"], "description": "defaults to 'anonymous' if absent" },
        "role_key":  { "type": "string", "required": false },
        "now_iso":   { "type": "string", "required": false, "description": "ISO timestamp override · tests only" }
      },
      "headers_dev": {
        "X-PTT-User-Id":   "preferred over ?user= when both present",
        "X-PTT-Tenant-Id": "preferred over ?tenant=",
        "X-PTT-Tier":      "preferred over ?tier=",
        "X-PTT-Env":       "preferred over ?env=",
        "X-PTT-Role-Key":  "preferred over ?role_key=",
        "X-Request-Id":    "echoed in response · generated if absent"
      },
      "response_200_example": {
        "ok": true,
        "data": {
          "flag_key": "flags.registry_v1",
          "value": true,
          "source": "stage-internal",
          "stage": "internal",
          "bucket": null,
          "cached": false,
          "deps_evaluated": [],
          "trace": ["[1] flag_exists_check...", "[8] rollout_stage_map: internal · tier=staff → true"],
          "evaluated_at": "2026-04-18T00:00:00.000Z",
          "evaluator_version": "2a_plus-py-port"
        },
        "error": null,
        "service": {
          "service_version": "2a_plus-py-scaffold-0.1",
          "evaluator_version": "2a_plus-py-port",
          "request_id": "…"
        }
      },
      "response_codes": {
        "200": "evaluation returned · inspect data.source for outcome (including unknown_flag)",
        "400": "invalid_request (missing user context, bad tier, malformed query)",
        "503": "registry_unavailable (registry failed to load at startup)"
      }
    },

    {
      "id": "ep-batch",
      "method": "POST",
      "path": "/api/flags/eval/batch",
      "description_en": "Evaluate multiple flags in one round-trip. Context is shared across the batch unless overridden per item.",
      "request_body_shape": {
        "context": {
          "type": "object",
          "required": false,
          "description": "shared context · merged with per-item overrides · same fields as /eval query",
          "fields": {
            "tenant_id": "string?",
            "user_id":   "string",
            "env":       "enum",
            "tier":      "enum",
            "role_key":  "string?",
            "now_iso":   "string?",
            "overrides": "object?"
          }
        },
        "flags": {
          "type": "array<object|string>",
          "required": true,
          "description": "each item is either a flag_key string OR {flag_key, ...context overrides}",
          "example": ["flags.registry_v1", {"flag_key": "cases.runtime_v1", "tier": "admin"}]
        }
      },
      "response_200_example": {
        "ok": true,
        "data": [
          { "flag_key": "flags.registry_v1", "value": true, "source": "stage-internal", "...": "..." },
          { "flag_key": "cases.runtime_v1",  "value": false, "source": "dep_unsatisfied", "...": "..." }
        ],
        "error": null,
        "service": { "service_version": "2a_plus-py-scaffold-0.1", "evaluator_version": "2a_plus-py-port", "request_id": "…" }
      },
      "response_codes": {
        "200": "array of evaluator results · one per input item (same order)",
        "400": "invalid_request (missing context, bad shape)",
        "503": "registry_unavailable"
      }
    },

    {
      "id": "ep-registry",
      "method": "GET",
      "path": "/api/flags/registry",
      "description_en": "Read-only registry snapshot · for tooling / debug UIs. No write path exists in this phase (flip UI is Phase 3).",
      "query_params": {
        "summary": { "type": "boolean", "required": false, "description": "if true · returns only {count, flags:[{key, rollout_stage, sensitive_flag, requires_approval}]}" }
      },
      "auth_future": "Phase 2b: require tier>=staff OR admin · currently open in dev",
      "response_200_example": {
        "ok": true,
        "data": { "schema_version": "…", "flags": ["…"], "count": 16 },
        "error": null,
        "service": { "service_version": "2a_plus-py-scaffold-0.1", "evaluator_version": "2a_plus-py-port", "request_id": "…" }
      },
      "response_codes": {
        "200": "registry returned",
        "503": "registry_unavailable"
      }
    },

    {
      "id": "ep-health",
      "method": "GET",
      "path": "/api/flags/health",
      "description_en": "Liveness + readiness probe · returns registry status + version info · no auth",
      "response_200_example": {
        "ok": true,
        "data": {
          "status": "ready",
          "registry_loaded": true,
          "flag_count": 16,
          "uptime_seconds": 42,
          "service_version": "2a_plus-py-scaffold-0.1",
          "evaluator_version": "2a_plus-py-port"
        }
      },
      "response_codes": {
        "200": "service is ready",
        "503": "registry_unavailable (startup failed to parse registry)"
      }
    }
  ],

  "auth_strategy": {
    "phase_2a_plus_reality": "Dev-mode only. No JWT verification. Context is read from X-PTT-* headers or query params. Service binds to localhost by default. Not safe to expose on a public network.",
    "dev_mode_context_resolution_order": [
      "1. X-PTT-* headers (if present, win)",
      "2. Query params (?user=, ?tenant=, ?tier=, ?env=, ?role_key=)",
      "3. POST body context object (batch endpoint only)",
      "4. Defaults: env='dev', tier='anonymous', tenant_id=null"
    ],
    "missing_user_id_rule": "If no user_id is provided via any source → return 400 invalid_request. user_id is required for deterministic bucketing.",
    "phase_2b_plan": {
      "mechanism": "JWT bearer · Authorization: Bearer <token>",
      "claims_required": ["sub (user_id)", "tenant_id", "tier", "role_key", "exp"],
      "signing": "RS256 · public key resolved via IdP JWKS endpoint",
      "fallback_to_dev_headers": "Disabled when JWT middleware is active",
      "rate_limit": "Per-subject token bucket · not in 2a+ scope",
      "deferred_because": "Requires IdP integration + JWKS rotation handling + override store (2b) coupling"
    },
    "phase_3_plan": {
      "admin_scope": "Admin UI write paths (flip · approve · rollback) require staff/admin tier + approval matrix binding · not in scope here"
    }
  },

  "error_codes": {
    "invalid_request": {
      "http_status": 400,
      "when": "Required field missing (flag_key, user_id) · tier/env out of enum · malformed JSON body",
      "action": "Fix the request · do not retry"
    },
    "unknown_flag": {
      "http_status": 200,
      "note": "This is surfaced INSIDE the evaluator result (data.source='unknown_flag') rather than as a 404, because the evaluator returns a deterministic default for unknown flags — the request itself is valid.",
      "alertable_metric": "flag_eval_unknown"
    },
    "registry_unavailable": {
      "http_status": 503,
      "when": "registry.json failed to parse at startup OR the service cannot find it · the service refuses to serve eval/batch in this state",
      "action": "Check registry path · validate against schema.json · restart service"
    },
    "internal_error": {
      "http_status": 500,
      "when": "Uncaught evaluator exception · should never happen given contract guarantees · logged + alertable",
      "action": "Capture trace · file regression in evaluator_examples.json"
    }
  },

  "cache_strategy": {
    "phase_2a_plus_reality": "No cache. Every request re-evaluates from the in-process registry. Expected latency: sub-millisecond given registry size (16 flags).",
    "phase_2a_plus_plan_redis": {
      "not_live": true,
      "intended_key_pattern": "ff:<flag_key>:<tenant_id|'_'>:<user_id>:<env>:<tier>",
      "intended_ttl_seconds": 60,
      "intended_invalidation": "pubsub channel ff_flips · receiver clears matching prefix",
      "why_deferred": "Redis cluster provisioning + pubsub wiring are Phase 2a+ infra work · not part of this contract scaffold"
    }
  },

  "observability": {
    "phase_2a_plus_reality": "Per-request log line to stdout · JSON format · includes request_id, flag_key, source, tenant, tier, duration_ms. No metrics export.",
    "log_shape": {
      "ts": "ISO",
      "level": "info|warn|error",
      "request_id": "uuid",
      "event": "flag_eval|flag_batch|registry_read|health|error",
      "flag_key": "string?",
      "source": "string?",
      "duration_ms": "number",
      "tenant_id": "string?",
      "tier": "string?"
    },
    "future_plan": {
      "prometheus_metrics": ["flag_eval_total{source,flag_key}", "flag_eval_duration_seconds", "flag_eval_unknown_total", "registry_reload_total"],
      "tracing": "OTel spans per eval · propagated via traceparent header",
      "deferred_because": "Metrics stack (Prometheus · OTel collector) is a separate infrastructure track"
    }
  },

  "determinism_parity": {
    "guarantee": "For the same (registry, request) the HTTP service returns the same (value, source, bucket) as evaluator.js would for the same inputs.",
    "enforced_by": "docs/runtime/feature-flags-service/verify_parity.py · loads evaluator_examples.json · runs Python port · must be 19/19 pass · run in CI before any deployment",
    "divergence_policy": "Any parity failure blocks release · evaluator.js is the reference · Python port must be corrected to match"
  },

  "non_goals_phase_2a_plus": [
    "JWT / IdP / JWKS integration (Phase 2b)",
    "Override stores: flag_tenant_overrides · flag_user_overrides (Phase 2b)",
    "Redis cache + pubsub invalidation (Phase 2a+ infra · scaffold only documents placement)",
    "Transition API · PATCH rollout_stage · rollback (Phase 2c)",
    "Admin flip UI · kanban · approval wiring (Phase 3)",
    "Rate limiting · quota enforcement (Phase 2b)",
    "Multi-registry / multi-tenant registry (Phase 2c)",
    "Hot reload · SIGHUP registry reparse (deferred)",
    "Production hardening · TLS · secret management (outside runtime layer)"
  ],

  "phase_2a_plus_deliverables": [
    "http_contract.json (this file)",
    "app.py (FastAPI service · 4 endpoints: eval · batch · registry · health)",
    "evaluator.py (Python 1:1 port of evaluator.js · same 9-step order · same hash function)",
    "verify_parity.py (regression runner · 19/19 pass required)",
    "requirements.txt (fastapi · uvicorn)",
    "run.sh (single-command start)",
    "service.html (local HTTP boundary debug page)",
    "README.md (Phase 2a+ overview · run instructions · curl samples · phase gates)"
  ]
}
