# Feature Flag Service · Phase 2a+ HTTP Boundary **Owner:** Session A · A-owned implementation layer **Baseline:** `A-runtime-phase-2a-plus` **Preview URL:** `/runtime/feature-flags-service/service.html` **Parity reference:** `/runtime/feature-flags/` (Phase 1 + Phase 2a source of truth) ## What this phase delivers - **HTTP contract** (`http_contract.json`) describing request/response envelope, error codes, auth strategy, and observability shape. - **FastAPI scaffold** (`app.py`) with 4 endpoints: `eval` · `batch` · `registry` · `health`. - **Python evaluator port** (`evaluator.py`) that mirrors `evaluator.js` 1:1, including the `djb2-xor` hash. - **Parity checker** (`verify_parity.py`) running all 19 Phase 2a example vectors through the Python port. Gate: must be 19/19 before `run.sh` binds the port. - **Single-command starter** (`run.sh`) that creates a venv, installs deps, verifies parity, and launches the service. - **Debug surface** (`service.html`) with tabs for run, endpoints, auth matrix, curl samples, and the raw contract. ## What this phase does NOT deliver - No JWT / IdP integration. Dev-mode auth shim only (`X-PTT-*` headers or query params). - No Redis cache. No pubsub invalidation. Every request re-evaluates from the in-process registry. - No override stores (`flag_tenant_overrides`, `flag_user_overrides`). Overrides only exist if passed inline in the request. - No transition API (`PATCH` rollout_stage, rollback). Defer to Phase 2c. - No admin flip UI. Defer to Phase 3. - No TLS / secret management / rate limiting. Localhost only. ## File layout ``` docs/runtime/feature-flags-service/ ├── README.md ← this file ├── http_contract.json ← HTTP envelope · endpoints · errors · auth strategy ├── app.py ← FastAPI service (4 endpoints) ├── evaluator.py ← Python 1:1 port of evaluator.js ├── verify_parity.py ← 19-example parity gate ├── requirements.txt ← fastapi · uvicorn ├── run.sh ← one-shot dev starter └── service.html ← local HTTP boundary debug page ``` ## Quick start ```bash bash docs/runtime/feature-flags-service/run.sh ``` The script creates `.venv/`, installs dependencies, runs the parity checker, and starts `uvicorn` on `http://127.0.0.1:8080`. If parity fails, the script aborts before binding. Then in another terminal: ```bash # Health curl -s http://127.0.0.1:8080/api/flags/health | jq . # Single eval curl -s 'http://127.0.0.1:8080/api/flags/eval?key=flags.registry_v1&user=U-1&tier=staff&env=prod' | jq .data # Batch eval curl -s -X POST 'http://127.0.0.1:8080/api/flags/eval/batch' \ -H 'Content-Type: application/json' \ -d '{"context":{"user_id":"U-batch","tier":"staff","env":"prod","tenant_id":"pty-zeroth"}, "flags":["flags.registry_v1","cases.runtime_v1","wizard.runtime_v1"]}' | jq .data # Registry summary curl -s 'http://127.0.0.1:8080/api/flags/registry?summary=true' | jq '.data | {count, flags: .flags[0:3]}' ``` Or open `docs/runtime/feature-flags-service/service.html` in a browser for a GUI tester. ## Endpoints at a glance | Method | Path | Purpose | Required | |---|---|---|---| | `GET` | `/api/flags/eval` | Evaluate one flag | `key`, `user_id` (via query or header) | | `POST` | `/api/flags/eval/batch` | Evaluate many flags with shared context | body `{context, flags[]}` · `context.user_id` required | | `GET` | `/api/flags/registry` | Read-only snapshot · `?summary=true` for condensed list | — | | `GET` | `/api/flags/health` | Liveness + readiness | — | All responses share the same envelope: ```json { "ok": true, "data": { "…evaluator result, array, or registry snapshot…" }, "error": null, "service": { "service_version": "2a_plus-py-scaffold-0.1", "evaluator_version": "2a_plus-py-port", "request_id": "uuid-v4" } } ``` Errors carry `ok: false` and an `error: {code, message, hint?}` block. HTTP status codes: `400 invalid_request`, `503 registry_unavailable`, `500 internal_error`. **Unknown flags return HTTP 200** with `data.source = "unknown_flag"` — that is a valid evaluation, not a request error. ## Dev-mode auth | Header | Query fallback | Meaning | |---|---|---| | `X-PTT-User-Id` | `?user=` | Required · stable id for bucketing | | `X-PTT-Tenant-Id` | `?tenant=` | Optional for platform flags | | `X-PTT-Tier` | `?tier=` | Default `anonymous` | | `X-PTT-Env` | `?env=` | Default `dev` | | `X-PTT-Role-Key` | `?role_key=` | Optional | | `X-Request-Id` | — | Correlation id · echoed in response | Headers win when both are present. Missing `user_id` always returns `HTTP 400 invalid_request` — bucketing determinism requires a stable id. **JWT comes in Phase 2b** and will replace this shim. Claims: `sub`, `tenant_id`, `tier`, `role_key`, `exp`. Signing: RS256 via IdP JWKS. ## Parity enforcement The Python evaluator must stay in lock-step with `evaluator.js`. The gate is: ```bash python3 docs/runtime/feature-flags-service/verify_parity.py ``` Expected tail line: **`19 pass · 0 fail · parity OK`**. This runs: - 11 real-registry examples (all current draft-stage flags) - 8 synthetic-registry examples (covers paused, rolled_back, override precedence, staged bucketing) - Hash determinism cross-check (`hash_bucket('bucket-low-1|syn.staged_25') == 4`) - Evaluate idempotency check `run.sh` refuses to start the service if parity breaks. If the Python port drifts, fix `evaluator.py` to match `evaluator.js` — the JS module is the reference implementation, not the Python one. The hash outputs have been verified identical across both runtimes for a range of inputs (short strings, composite ids, long strings). See the commit trail or re-run the cross-check manually: ```bash node -e "const FF=require('./docs/runtime/feature-flags/evaluator.js'); console.log(FF.hashBucket('bucket-low-1|syn.staged_25'))" python3 -c "import sys; sys.path.insert(0,'docs/runtime/feature-flags-service'); from evaluator import hash_bucket; print(hash_bucket('bucket-low-1|syn.staged_25'))" ``` Both must print `4`. ## How Phase 2a+ relates to Phase 2a - **Phase 2a** ships the **contract** + **JS module** + **examples** + **debug page**. Evaluator logic is callable from the browser or Node — no HTTP boundary. - **Phase 2a+** adds the **HTTP boundary** around a **parity-locked Python port** of that same logic. No semantic change. The wire envelope, error codes, and observability shape are new. - **Phase 2b** will add JWT + override stores (Redis + Postgres tables). That is when the service becomes safe to expose beyond localhost. ## Environment variables | Var | Default | Purpose | |---|---|---| | `FF_PORT` | `8080` | Bind port | | `FF_HOST` | `127.0.0.1` | Bind host | | `FF_VENV` | `.venv` | Virtualenv path | | `FF_REGISTRY_PATH` | `../feature-flags/registry.json` (resolved from `app.py`) | Where to load the registry | | `FF_LOG_LEVEL` | `INFO` | Python logger level | ## Ownership boundary - **A owns:** this folder and everything in it. The registry, evaluator contract, and example vectors remain in `../feature-flags/` (also A-owned). - **B contracts consumed (read-only):** same as Phase 2a — `tenant_scope.json#feature_flag_handoff`, `approval_matrix.json`, `publish_workflow.json`, `session_a_handoff.json#hand-feature-flags`. - **B files touched:** none. ## Honest summary An HTTP service now wraps the Phase 2a evaluator. Parity with `evaluator.js` is enforced before every boot. The service is runnable on a laptop in one command and speaks a stable, documented contract. It is **not** production-ready — no auth, no cache, no overrides, no transitions. Those are on the Phase 2b / 2c / 3 track. No flag in the registry is bound to any real production behaviour. The value proposition of this phase is that a downstream runtime can now call a network service instead of embedding the evaluator module. --- ## Phase 2b additions (additive · 2a+ untouched) The Phase 2a+ surface above is unchanged. Phase 2b adds **three capabilities** on top without breaking the 19/19 parity gate: ### New endpoints - `GET /api/flags/overrides/by-flag/{flag_key}` — read-only inspector for file-backed override rows - `POST /api/flags/sensitive-eval` — sensitivity profile + LOCAL policy_hint + gated flag eval - `GET /phase-2b/health` — extended health with override store + JWT verification status ### New files | Path | Purpose | |---|---| | `phase_2b_contract.json` | Phase 2b HTTP contract · auth resolution · override shape · sensitive gate · deferred | | `override_store_schema.json` | JSON-Schema draft-07 for override rows | | `override_examples.json` | 8 seed rows (tenant · user · TTL · approval_ref · expired-demo row) | | `phase-2b.html` | Runtime debug surface with JWT section · store section · 5 use-case presets | ### New env vars | Var | Default | Purpose | |---|---|---| | `FF_OVERRIDES_PATH` | `./override_examples.json` | Where to load the file-backed override store | | `POLICY_JWT_VERIFY` | unset (false) | Set to `true` to enable RS256 verification (requires PyJWT + key) | | `POLICY_JWT_PUBLIC_KEY` | unset | PEM-formatted public key for RS256 verification | | `POLICY_JWKS_URL` | unset | JWKS URL (rotation handling deferred) | ### Auth context resolution (Phase 2b) `Authorization: Bearer ` > `X-PTT-*` headers > query params > defaults. JWT is **decode-only** (and responses carry `warnings: ['auth_not_verified']` + `auth_source='jwt_unverified'`) unless ALL three preconditions hold: PyJWT installed + `POLICY_JWT_VERIFY=true` + key/JWKS env set. ### Override precedence (Phase 2b) 1. inline `request.overrides.user[flag_key]` (Phase 2a semantics preserved) 2. inline `request.overrides.tenant[flag_key]` 3. store user-row matching (flag_key, user_id) · non-expired 4. store tenant-row matching (flag_key, tenant_id) · non-expired 5. evaluator staged bucketing / rollout_stage_map / default Expired rows are treated as absent but kept in the file for inspection. `approval_ref` on a row is display-only (admin store not consulted). ### What Phase 2b does NOT change - Phase 2a+ endpoints (`eval`, `batch`, `registry`, `health`) — byte-identical behaviour - Evaluator module (`evaluator.py`) — parity gate still 19/19 - Write path — service remains read-only; flag flip / transition API deferred to Phase 2c - Audit sink — stdout only; Kafka `ptt.audit.trail` deferred - Cache layer — no Redis, no invalidation For the **real** policy-first / eval-second orchestration against the Admin Policy Engine, use the [Cross-Runtime Integration Demo](../cross-runtime-integration/) — `/api/flags/sensitive-eval` deliberately stays in-process (no HTTP call out to admin). ### Phase 2b planning document Full rationale + DoD + deferred matrix: [/planning/feature-flags-phase-2b.html](../../planning/feature-flags-phase-2b.html)