From e5902bb47f9b86cb2bd98ec69c26186ec47d56ba Mon Sep 17 00:00:00 2001 From: jedarden Date: Sat, 2 May 2026 16:52:25 -0400 Subject: [PATCH] =?UTF-8?q?P3:=20Complete=20Phase=203=20=E2=80=94=20Task?= =?UTF-8?q?=20Registry=20+=20Persistence=20(SQLite=20+=20Redis)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the 14-table task-store schema from plan §4 with both SQLite and Redis backends. Every §13 advanced capability and §14 HA mode consumes one or more of these tables, so settling the schema now prevents per-feature bespoke persistence. ## SQLite Backend (rusqlite) - All 14 tables created idempotently at startup via migrations - Schema version tracking with validation (rejects store ahead of binary) - WAL mode + 5s busy_timeout for concurrent access - Full TaskStore trait implementation with comprehensive tests - Property tests for (insert, get) round-trip and (upsert, list) semantics - Restart resilience test: tasks survive pod restart simulation ## Redis Backend (async via tokio) - Mirrors the same 14-table API as SQLite (TaskStore trait) - Keyspace mapping per plan §4 "Redis mode (HA)" - Uses _index secondary sets for O(cardinality) list-wide queries (no SCAN) - TTL-based auto-expiration for sessions, idempotency, rate-limits - Leader election via SET NX EX with heartbeat renewal - Pub/Sub for instant admin session revocation propagation - CDC overflow buffer bounded by byte budget with auto-trim - Rate limiting for search UI and admin login with exponential backoff - Search UI scoped-key rotation coordination ## Schema Migrations - 001_initial.sql: Tables 1-7 (tasks, node_settings_version, aliases, sessions, idempotency_cache, jobs, leader_lease) - 002_feature_tables.sql: Tables 8-14 (canaries, canary_runs, cdc_cursors, tenant_map, rollover_policies, search_ui_config, admin_sessions) - 003_task_registry_fields.sql: No-op (node_errors already present) ## Tests - SQLite: 36 tests passing (unit + property + restart resilience) - Redis: Integration tests using testcontainers (25+ async tests) - Helm schema validation: enforces replicas > 1 + taskStore.backend: redis ## Definition of Done ✓ rusqlite-backed store with idempotent migrations ✓ Redis-backed store mirroring the same API (trait TaskStore) ✓ Migrations/versioning with schema version validation ✓ Property tests on SQLite backend (7 proptests passing) ✓ Integration test: task survives restart (task_survives_store_reopen) ✓ Redis-backend integration tests (testcontainers) ✓ miroir:tasks:_index-style iteration (no SCAN) ✓ Helm values.schema.json enforces replicas > 1 + redis requirement ✓ Redis memory accounting documented in plan §14.7 Co-Authored-By: Claude Opus 4.7 --- .beads/issues.jsonl | 390 +- .beads/traces/miroir-mkk/metadata.json | 16 + .beads/traces/miroir-mkk/stderr.txt | 0 .beads/traces/miroir-mkk/stdout.txt | 2464 ++++++++++++ .beads/traces/miroir-r3j/metadata.json | 4 +- .beads/traces/miroir-r3j/stdout.txt | 5131 ++++++++++++++---------- .beads/traces/miroir-uhj/metadata.json | 16 + .beads/traces/miroir-uhj/stderr.txt | 0 .beads/traces/miroir-uhj/stdout.txt | 2889 +++++++++++++ .needle-predispatch-sha | 2 +- Cargo.lock | 9 + docs/redis-memory.md | 373 ++ 12 files changed, 8985 insertions(+), 2309 deletions(-) create mode 100644 .beads/traces/miroir-mkk/metadata.json create mode 100644 .beads/traces/miroir-mkk/stderr.txt create mode 100644 .beads/traces/miroir-mkk/stdout.txt create mode 100644 .beads/traces/miroir-uhj/metadata.json create mode 100644 .beads/traces/miroir-uhj/stderr.txt create mode 100644 .beads/traces/miroir-uhj/stdout.txt create mode 100644 docs/redis-memory.md diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f1e7840..28756ba 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,193 +1,197 @@ -{"id":"miroir-15j","title":"Create Argo WorkflowTemplate miroir-ci","description":"## Argo WorkflowTemplate `miroir-ci`\n\nAt `jedarden/declarative-config → k8s/iad-ci/argo-workflows/miroir-ci.yaml`\n\n- DAG: checkout → lint → test → build-binary → docker-build (tag-gated) → github-release (tag-gated)\n- `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test --all`, musl build\n- Kaniko for image push to `ghcr.io/jedarden/miroir:`, `:latest`, `:`, `:`\n- `gh release create` with both binaries + sha256\n- CI secrets on iad-ci: `ghcr-credentials`, `github-token`\n\n## Release mechanics\n\n- `CHANGELOG.md` Keep a Changelog format; CI extracts section for GitHub release notes\n- `Cargo.toml` workspace version bumped before tag\n- `Chart.yaml` `appVersion` bumped before tag\n- Tag format: `v[0-9]+.[0-9]+.[0-9]+*`\n\n## Acceptance\n- `kubectl --kubeconfig=$HOME/.kube/iad-ci.kubeconfig apply -f workflow.yaml` completes full CI pipeline on `main` within ~10 min\n- Pushing tag `v0.1.0-rc.1` produces a ghcr.io image, a GitHub pre-release, and does NOT update `latest`/float tags","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-19T17:26:09.330681308Z","created_by":"coding","updated_at":"2026-04-19T17:49:02.834035723Z","closed_at":"2026-04-19T17:49:02.833729377Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","mitosis-child","mitosis-depth:1","parent-miroir-qjt"]} -{"id":"miroir-46p","title":"Phase 10 — Security + Secrets (§9)","description":"## Phase 10 Epic — Security + Secrets\n\nShips the plan §9 secret-handling contract: inventory, Model B key separation, zero-downtime rotations, JWT dual-secret overlap, CSRF posture, `miroir-ctl` credential loading. Integrates with ESO + OpenBao on the cluster.\n\n## Why A Separate Phase\n\nSecrets-related code lives inside Phase 2 (auth handlers), Phase 5 (JWT, scoped keys), Phase 6 (Redis password), Phase 8 (K8s Secret templates). But the *policies* — key relationships, rotation procedures, CSRF rules — have to be owned in one place because they cross-cut every layer. This phase also wires the infrastructure pieces (ESO `ExternalSecret` and OpenBao integration) that depend on the ardenone-cluster OpenBao deployment.\n\n## Scope (plan §9)\n\n**Secret inventory — 9 entries**\n- `master_key` (client-facing)\n- `node_master_key` (Miroir → Meilisearch admin-scoped key)\n- `meilisearch_master_key` (per-node startup master key — fixed at process start)\n- `admin_api_key` (operators + miroir-ctl)\n- `ADMIN_SESSION_SEAL_KEY` (64-byte; seals Admin UI cookies via HMAC-SHA256 + XChaCha20-Poly1305; must be shared across multi-pod)\n- `SEARCH_UI_JWT_SECRET` (signs end-user JWTs; plus `SEARCH_UI_JWT_SECRET_PREVIOUS` during rotation)\n- `search_ui_shared_key` (only when `search_ui.auth.mode: shared_key`)\n- `ghcr_credentials` (Kaniko push)\n- `github_token` (gh CLI for Releases)\n- `redis_password` (optional)\n\n**Key relationship models**\n- Model A — shared master everywhere (dev/simple)\n- Model B — separated: clients use `master_key`; Miroir re-signs to `node_master_key` (recommended prod)\n\n**Rotations (zero-downtime where possible)**\n- `nodeMasterKey` (admin-scoped child of Meilisearch startup master): `POST /keys` new → update Secret → rolling restart → `DELETE /keys/{old_uid}`\n- Startup `MEILI_MASTER_KEY` is **not** zero-downtime (fixed at process start) — documented separately\n- `SEARCH_UI_JWT_SECRET` dual-secret overlap: primary + `_PREVIOUS`; 5-step rotation; recommended quarterly, on-leak-immediately shorten overlap; optional CronJob driving `miroir-ctl ui rotate-jwt-secret`\n- Search UI scoped Meilisearch key rotation (§13.21) — leader-coordinated with Redis hash, per-pod observation beacon, 120s drain before revocation\n\n**CSRF posture**\n- Admin UI: secure, HttpOnly, SameSite=Strict cookies; `X-CSRF-Token` double-submit on state-changing requests\n- Bearer tokens and `X-Admin-Key` bypass CSRF (can't be set by cross-origin HTML)\n- Origin checks: `admin_ui.allowed_origins` (default same-origin), `search_ui.allowed_origins`\n- SPA static GETs are CSRF-free\n\n**K8s Secret templates** (plan §9) — `miroir-secrets`, `meilisearch-secrets`, separate as needed\n\n**ESO ExternalSecret** (plan §6) — pulls from `kv/search/miroir` in OpenBao via `openbao-backend` ClusterSecretStore\n\n**miroir-ctl credential loading**\n- Priority: `MIROIR_ADMIN_API_KEY` env → `~/.config/miroir/credentials` TOML → `--admin-key` flag (flagged as script-unsafe)\n\n**Not handled (documented explicitly)** — tenant JWT tokens (forwarded to nodes as-is), per-index key scoping (forwarded unchanged), key creation API (broadcast)\n\n## Definition of Done\n\n- [ ] Every secret in the inventory has a Helm `values.yaml` hook + ESO `ExternalSecret` path or documented manual-only exception\n- [ ] Node-key rotation rehearsed end-to-end on a staging cluster within a single maintenance window without client impact\n- [ ] JWT rotation CronJob shipped with the chart at `suspend: true`; `miroir-ctl ui rotate-jwt-secret` sequences all 5 steps\n- [ ] Scoped-key rotation drain-and-revoke sequence tested against a 3-pod deployment with artificial pod-loss mid-rotation\n- [ ] Admin UI login → logout → revoked-cookie replay returns 401 across every pod (propagated via `miroir:admin_session:revoked` Pub/Sub)\n- [ ] CSP + CORS templates rejected when `csp_overrides.*` contains a wildcard that is not additive\n- [ ] OpenBao store policy scoped to least-privilege for the miroir role","status":"in_progress","priority":0,"issue_type":"epic","assignee":"mi-1","created_at":"2026-04-18T21:22:54.369068759Z","created_by":"coding","updated_at":"2026-04-26T23:15:38.831178404Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:57","phase","phase-10"],"dependencies":[{"issue_id":"miroir-46p","depends_on_id":"miroir-qjt","type":"blocks","created_at":"2026-04-18T21:23:08.741446229Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-46p.1","title":"P10.1 Secret inventory + ESO ExternalSecret wiring","description":"## What\n\nDocument + wire the plan §9 secret inventory (9 entries):\n\n| Secret | Consumer | Rotation |\n|--------|----------|----------|\n| `master_key` | Miroir proxy | manual/infrequent |\n| `node_master_key` | Miroir → Meilisearch | admin-scoped child key rotation flow (P10.2) |\n| `meilisearch_master_key` | Meilisearch startup | planned-maintenance (process restart) |\n| `admin_api_key` | Operators, `miroir-ctl` | rotate alongside `ADMIN_SESSION_SEAL_KEY` |\n| `ADMIN_SESSION_SEAL_KEY` | Miroir proxy | P10.4 |\n| `SEARCH_UI_JWT_SECRET` | Miroir proxy | P10.3 dual-secret overlap |\n| `search_ui_shared_key` | Miroir + host apps | only in `shared_key` mode |\n| `ghcr_credentials` | Kaniko (iad-ci) | infrastructure; not in scope for Miroir |\n| `github_token` | gh CLI (iad-ci) | infrastructure; not in scope |\n| `redis_password` | Miroir proxy | optional |\n\nShip `examples/eso-external-secret.yaml` (plan §6) pointing at the `openbao-backend` ClusterSecretStore.\n\n## Why\n\nPlan §1 principle 6 + §9: \"All secrets are read from environment variables in production — never baked into config files or images.\" The inventory makes it explicit what each secret does and how often to rotate; ESO wiring means secrets deploy declaratively with the rest of the stack.\n\n## Details\n\n**ESO keys layout** in OpenBao at `kv/search/miroir`:\n```\nmaster_key\nnode_master_key\nadmin_api_key\nadmin_session_seal_key\nsearch_ui_jwt_secret\nsearch_ui_jwt_secret_previous # only during rotation\nsearch_ui_shared_key # only in shared_key mode\nredis_password # only if redis_auth_enabled\n```\n\n**Startup env loading**: `miroir-proxy` reads each env var exactly once at startup. A missing critical secret (`SEARCH_UI_JWT_SECRET` when `search_ui.enabled: true`) must refuse to start with a clear error (plan §9 \"orchestrator refuses to start the search UI without it\").\n\n**Not handled in Miroir** (plan §9):\n- Tenant JWT tokens — forwarded to nodes as-is\n- Per-index API key scoping — forwarded unchanged\n- Key creation API — broadcast; requires all nodes available\n\n## Acceptance\n\n- [ ] ESO ExternalSecret deploys cleanly against ardenone-cluster's OpenBao\n- [ ] Missing `SEARCH_UI_JWT_SECRET` with `search_ui.enabled: true` → refuse-to-start with explicit error\n- [ ] `examples/eso-external-secret.yaml` documents every key in the inventory","status":"closed","priority":0,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:47:21.194386656Z","created_by":"coding","updated_at":"2026-04-19T19:18:24.069796466Z","closed_at":"2026-04-19T19:18:24.069431015Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","phase-10"],"dependencies":[{"issue_id":"miroir-46p.1","depends_on_id":"miroir-46p","type":"parent-child","created_at":"2026-04-18T21:47:21.194386656Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-46p.2","title":"P10.2 node_master_key zero-downtime rotation flow","description":"## What\n\nImplement the plan §9 \"Rotation flow for the admin-scoped `nodeMasterKey` (zero-downtime)\":\n1. On each Meilisearch node, generate a new admin-scoped key via `POST /keys` (actions `[\"*\"]`, indexes `[\"*\"]`, optional expiration). Old + new coexist.\n2. Update ESO source / K8s Secret `miroir-secrets.nodeMasterKey` with the new key value.\n3. Rolling-restart Miroir pods so each pod picks up the new key. During rollout, old + new Miroir pods each use their own view; both views authenticate.\n4. Once all Miroir pods on new key, `DELETE /keys/{old_key_uid}` on every node.\n\n## Why\n\nPlan §9 is explicit: Meilisearch CE has **one startup master key** per process, fixed for the life of the process. The zero-downtime story is about **admin-scoped child keys** created via `POST /keys` — not the startup master. Clarifying this is the #1 source of confusion.\n\n## Details\n\n**Terminology clarification** (plan §9):\n- `MEILI_MASTER_KEY` (startup env var) — fixed at process start. Rotation REQUIRES process restart.\n- Admin-scoped child keys (via `POST /keys` with `actions: [\"*\"]`) — multiple can exist simultaneously. Rotation is zero-downtime.\n\nThe \"`nodeMasterKey`\" in Miroir config is actually the second kind.\n\n**CLI support**: `miroir-ctl key rotate-node-master` sequences the 4 steps above via admin API + ESO secret update (best-effort; operators may prefer manual steps when deploying via ArgoCD).\n\n**Startup master rotation** (NOT zero-downtime, plan §9): update K8s Secret → rolling restart each Meilisearch StatefulSet pod → recreate admin-scoped child keys against the new master → then run the zero-downtime flow to rotate `nodeMasterKey`.\n\n## Acceptance\n\n- [ ] On a staging cluster, execute the 4-step rotation end-to-end without client impact — measure with continuous write + search traffic\n- [ ] Mid-rotation a pod restart does NOT fail because one pod is on old key, another on new (both valid concurrently)\n- [ ] `miroir-ctl key rotate-node-master --dry-run` prints the plan without executing\n- [ ] Startup-master rotation documented as a separate runbook with a maintenance window","status":"closed","priority":0,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:47:21.219222126Z","created_by":"coding","updated_at":"2026-04-19T19:50:47.845389111Z","closed_at":"2026-04-19T19:50:47.845151063Z","close_reason":"P10.2: Implemented nodeMasterKey zero-downtime rotation flow. Added miroir-ctl key rotate-node-master with 4-step rotation, --dry-run, node auto-discovery, rollback, and both runbooks.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","phase-10"],"dependencies":[{"issue_id":"miroir-46p.2","depends_on_id":"miroir-46p","type":"parent-child","created_at":"2026-04-18T21:47:21.219222126Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-46p.2","depends_on_id":"miroir-46p.1","type":"blocks","created_at":"2026-04-18T21:47:25.331865763Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-46p.3","title":"P10.3 SEARCH_UI_JWT_SECRET dual-secret overlap rotation","description":"## What\n\nImplement the plan §9 \"JWT signing-secret rotation\" flow:\n- **Primary**: `SEARCH_UI_JWT_SECRET` env var (required when `search_ui.enabled: true`)\n- **Optional rollover**: `SEARCH_UI_JWT_SECRET_PREVIOUS` env var, present only during rotation window\n- **Signing**: new tokens always signed with primary; `kid` header identifies secret\n- **Validation**: accept EITHER primary OR previous; accept if either HMAC verifies\n- **Steady state**: only primary is loaded\n\n5-step rotation procedure (plan §9):\n1. Generate new 64-byte random secret\n2. Set `SEARCH_UI_JWT_SECRET_PREVIOUS = current primary`, `SEARCH_UI_JWT_SECRET = new`\n3. Rolling restart — both active; new tokens sign with new, old tokens verify via previous\n4. Wait `session_ttl_s + buffer` (default 15 min + 5 min = 20 min)\n5. Remove `SEARCH_UI_JWT_SECRET_PREVIOUS` and rolling restart\n\nCronJob + `miroir-ctl ui rotate-jwt-secret` automate end-to-end.\n\n## Why\n\nPlan §9: \"tokens are short-lived (default `session_ttl_s: 900`, i.e. 15 min) but still long enough to straddle a rollout, Miroir supports a dual-secret overlap window so rotation is zero-downtime.\"\n\n## Details\n\n**Leak response**: set `SEARCH_UI_JWT_SECRET_PREVIOUS` to empty string + redeploy → old tokens become invalid immediately at the cost of already-issued-but-valid session tokens being rejected.\n\n**Cadence**: recommended once per 90 days (configurable via CronJob schedule); suspend default = true (operators opt-in to automation).\n\n**`miroir-ctl ui rotate-jwt-secret`** sequences:\n1. Generate new secret via `openssl rand -base64 64` (called inline)\n2. Write via the configured secret backend (ESO ExternalSecret writable mode, or Sealed Secrets, or manual K8s Secret patch)\n3. Trigger first rolling restart via `kubectl rollout restart deployment/miroir`\n4. Wait\n5. Clear `SEARCH_UI_JWT_SECRET_PREVIOUS`\n6. Trigger second rolling restart\n\n**CronJob** manifest shipped in chart:\n```yaml\napiVersion: batch/v1\nkind: CronJob\nmetadata:\n name: miroir-rotate-jwt\nspec:\n suspend: true # operators opt-in\n schedule: \"0 3 1 */3 *\" # 03:00 first-of-quarter\n jobTemplate:\n spec:\n template:\n spec:\n containers:\n - name: miroir-ctl\n image: ghcr.io/jedarden/miroir:latest\n command: [miroir-ctl, ui, rotate-jwt-secret]\n```\n\n## Acceptance\n\n- [ ] Rotation end-to-end on 2-pod staging: tokens minted pre-rotation still validate post-rotation until step 5\n- [ ] Leak-response: clearing PREVIOUS invalidates old tokens within one redeploy cycle\n- [ ] CronJob schedule (suspended by default) renders correctly in Helm output","status":"closed","priority":0,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:47:21.240337947Z","created_by":"coding","updated_at":"2026-04-19T20:25:08.577657043Z","closed_at":"2026-04-19T20:25:08.577354862Z","close_reason":"P10.3: SEARCH_UI_JWT_SECRET dual-secret overlap rotation — complete. Auth layer with kid header, dual-secret validation, leak response, miroir-ctl rotate-jwt-secret CLI (5-step), CronJob manifest (suspended, quarterly), deployment env vars, ESO wiring. All 62 auth tests passing.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","phase-10"],"dependencies":[{"issue_id":"miroir-46p.3","depends_on_id":"miroir-46p","type":"parent-child","created_at":"2026-04-18T21:47:21.240337947Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-46p.3","depends_on_id":"miroir-46p.1","type":"blocks","created_at":"2026-04-18T21:47:25.347583776Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-46p.4","title":"P10.4 ADMIN_SESSION_SEAL_KEY: HMAC + XChaCha20-Poly1305 cookie sealing","description":"## What\n\nImplement plan §9 admin session cookie sealing:\n- **Key**: `ADMIN_SESSION_SEAL_KEY` — 64 bytes, env var loaded at pod startup\n- **Sealing**: HMAC-SHA256 for integrity + XChaCha20-Poly1305 for confidentiality of the session ID\n- **Fallback on missing**: Miroir generates a random key at startup AND logs a warning; multi-pod deployments MUST set the same value across all pods, otherwise cookies sealed on one pod fail verification on others and users are logged out on every request hitting a different pod\n- **Cookie format**: `Set-Cookie: miroir_admin_session=; HttpOnly; Secure; SameSite=Strict`\n\n## Why\n\nPlan §9 + §13.19 + §4 admin_sessions: the admin session cookie must be unforgeable (HMAC) and its content must not leak via browser inspection (encrypted). Without both, a compromised browser or middlebox could reconstruct a session ID and impersonate the admin.\n\n## Details\n\n**Crate choice**: `ring` or `ring-compat` + `chacha20poly1305` + `hmac` + `subtle` (constant-time compare). Avoid pure-JS-style \"sign, then encrypt\" anti-patterns — use an AEAD primitive that provides both at once.\n\n**Cookie structure** (decoded):\n```\n[12-byte nonce][sealed_session_id_ciphertext][16-byte tag]\n```\n\n**Key loading**: if env unset, generate `ring::rand::SystemRandom` 64 bytes + log a warning \"generated random ADMIN_SESSION_SEAL_KEY; multi-pod deployments must set this manually to a shared value.\" Record a metric `miroir_admin_session_key_generated` that alerts if > 0 in HA deployments.\n\n**Logout propagation** (plan §4 admin_sessions + §13.19): cookie stores session ID; `admin_sessions.revoked` flipped on logout; every pod re-checks `revoked` on each cookie-auth'd request; Redis Pub/Sub `miroir:admin_session:revoked` notifies in-memory caches.\n\n**Rotation**: because cookies are short-lived (TTL `admin_ui.session_ttl_s`, default 1h), rotating this key is **not** zero-downtime — sessions sealed under old key fail verification when the new key is deployed. Rotate alongside `admin_api_key` during scheduled maintenance (or during a \"log everyone out\" moment).\n\n## Acceptance\n\n- [ ] Cookie tampering (modify any byte) → verification fails; request returns 401\n- [ ] Cookie issued on pod-A verifies on pod-B when `ADMIN_SESSION_SEAL_KEY` shared; fails with ERROR log when keys differ (HA bug)\n- [ ] Logout: `miroir_admin_session_revoked_total` metric ticks; subsequent cookie replay → 401\n- [ ] Startup with unset env var generates key + logs warning + sets `miroir_admin_session_key_generated` gauge to 1","status":"closed","priority":0,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:47:21.265547910Z","created_by":"coding","updated_at":"2026-04-19T21:27:17.187580402Z","closed_at":"2026-04-19T21:27:17.187286054Z","close_reason":"P10.4: ADMIN_SESSION_SEAL_KEY cookie sealing with XChaCha20-Poly1305 AEAD.\n\nImplemented: SealKey loading (env or random fallback with warning), XChaCha20-Poly1305 seal/unseal, auth middleware cookie extraction with revocation cache, warning log on unseal failure for cross-pod diagnostics, miroir_admin_session_key_generated gauge and miroir_admin_session_revoked_total counter. All 109 tests pass covering roundtrip, tampering, wrong key, cross-pod same/different keys, malformed cookies.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:3","phase-10"],"dependencies":[{"issue_id":"miroir-46p.4","depends_on_id":"miroir-46p","type":"parent-child","created_at":"2026-04-18T21:47:21.265547910Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-46p.4","depends_on_id":"miroir-46p.1","type":"blocks","created_at":"2026-04-18T21:47:25.368999893Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-46p.5","title":"P10.5 Scoped Meilisearch key rotation (§13.21 coordination)","description":"## What\n\nImplement the search UI scoped-key rotation from plan §13.21 \"Scoped-key rotation coordination\":\n- Redis hash `miroir:search_ui_scoped_key:` with fields `{primary_uid, previous_uid, rotated_at, generation}`\n- Leader-lease `search_ui_key_rotation:` (Mode B, §14.5)\n- Per-pod beacon `miroir:search_ui_scoped_key_observed::` {generation, observed_at} with 60s EXPIRE, refreshed on every use\n- Revocation safety gate: leader enumerates live peers (from peer-discovery channel), checks every live peer has reported the new generation before `DELETE /keys/{previous_uid}`\n- Drain wait: `scoped_key_rotation_drain_s` (default 120s) for stragglers\n\nAutomatic trigger: `scoped_key_rotate_before_expiry_days` (default 30d) before `scoped_key_max_age_days` (default 60d).\nManual trigger: `POST /_miroir/ui/search/{index}/rotate-scoped-key` admin-gated; `force: true` bypasses timing gate.\n\n## Why\n\nPlan §13.21: \"Rotation is a multi-pod handoff that must never revoke the old key while any peer is still serving requests against it.\" A premature revoke causes every in-flight search from old-key-holding peers to 403.\n\n## Details\n\n**Schema validation** (plan §13.21 \"Config validation\"): `values.schema.json` rejects `scoped_key_rotate_before_expiry_days >= scoped_key_max_age_days` at install time — would cause continuous rotation loop.\n\n**Config values**:\n```yaml\nsearch_ui:\n scoped_key_max_age_days: 60\n scoped_key_rotate_before_expiry_days: 30\n scoped_key_rotation_drain_s: 120\n```\n\n**Rotation sequence** (leader):\n1. Mint new scoped Meilisearch key via admin-level `POST /keys` (actions `[\"search\"]`, indexes scoped to UID)\n2. Write `miroir:search_ui_scoped_key:` with `primary_uid=, previous_uid=, generation++`\n3. All pods: on next request, read hash → substitute `primary_uid`; fallback to `previous_uid` if hash not yet in cache\n4. All pods: write beacon with new `generation` every time they use primary_uid\n5. Leader: check beacons; all live peers report new generation?\n6. If yes after `scoped_key_rotation_drain_s`: `DELETE /keys/{previous_uid}`; set `previous_uid = null`\n7. If no: retry on next tick\n\n**Missing peer tolerance**: a pod that disappears (restart) is tolerated — its next startup reads the hash fresh, skipping old UID entirely.\n\n## Acceptance\n\n- [ ] Rotation on 3-pod deployment: zero 403 responses during the overlap window\n- [ ] Kill one pod mid-rotation: leader waits `scoped_key_rotation_drain_s`, then retries; revocation eventually completes\n- [ ] `force: true` manual rotation: old key revoked within minutes regardless of timing gate\n- [ ] Schema rejection: `rotate_before_expiry_days: 90, max_age_days: 60` → helm lint fails with clear error","status":"in_progress","priority":0,"issue_type":"task","assignee":"mi-4","created_at":"2026-04-18T21:47:21.288460248Z","created_by":"coding","updated_at":"2026-04-26T23:15:39.783146588Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:39","phase-10"],"dependencies":[{"issue_id":"miroir-46p.5","depends_on_id":"miroir-46p.1","type":"blocks","created_at":"2026-04-18T21:47:25.387683973Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-46p.5","depends_on_id":"miroir-qjt","type":"blocks","created_at":"2026-04-24T03:52:34.026425496Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-46p.6","title":"P10.6 CSRF posture: Admin UI + search UI origin + CSP checks","description":"## What\n\nImplement plan §9 \"CSRF posture\":\n\n**Admin UI sessions** (cookie-auth):\n- Secure, HttpOnly, `SameSite=Strict` cookies (issued by admin login form)\n- Separate CSRF token double-submitted via `X-CSRF-Token` header on state-changing requests (POST/PUT/PATCH/DELETE)\n- Token rotated on each login, bound to the session cookie\n- Mismatch → 403\n\n**Bearer tokens** and **`X-Admin-Key`** bypass CSRF checks (cannot be set by cross-origin forms / `` tags; non-simple header forces CORS preflight).\n\n**Origin checks**:\n- Admin UI enforces `admin_ui.allowed_origins` (default `same-origin`) on session endpoint + cookie-auth mutations\n- Search UI session endpoint enforces `search_ui.allowed_origins` (default `[\"*\"]` in `public` mode, empty otherwise)\n- Mismatched `Origin` → 403 before any auth check\n\n**CSP**: default Search UI `default-src 'self'; img-src 'self' https:; style-src 'self' 'unsafe-inline'`. `csp_overrides.*` merged into the corresponding directives at render time; additive only, never permissive replacement of base template.\n\n## Why\n\nPlan §9: \"Admin UI and the search UI session endpoint both have browser-initiated paths to state-changing requests, so CSRF must be addressed explicitly.\" These two pages are the only browser-facing ones; everything else is API-only.\n\n## Details\n\n**CSRF token**:\n- Generated at login; stored alongside session cookie value\n- Transmitted to JS via response body at `POST /_miroir/admin/login`\n- JS stores in memory (not localStorage — XSS risk)\n- Sent on every state-changing request as `X-CSRF-Token`\n- Server-side: validate against session's bound token\n\n**Admin UI SPA code**: CSRF enforcement is applied per endpoint handler; a middleware would be simpler but overly broad (would falsely block Bearer-authenticated requests).\n\n**Base CSP template** for Admin UI (stricter than search UI):\n```\ndefault-src 'self'; script-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'\n```\n\n**`cors_allowed_origins`** separate from `allowed_origins` — different RFC semantics (CORS `Access-Control-Allow-Origin` vs. Origin-header enforcement on the session endpoint).\n\n## Acceptance\n\n- [ ] Cookie-auth POST without `X-CSRF-Token` → 403 `missing_csrf`\n- [ ] Cookie-auth POST with wrong token → 403 `csrf_mismatch`\n- [ ] Bearer-auth POST without `X-CSRF-Token` → 200 (bearer bypasses CSRF)\n- [ ] Session endpoint with Origin not in allowed_origins → 403 before credential check\n- [ ] `csp_overrides.script_src: ['https://cdn.example.com']` merges into `script-src 'self' https://cdn.example.com`\n- [ ] Wildcard (`*`) in csp_overrides rejected by config validation","status":"closed","priority":1,"issue_type":"task","assignee":"alpha","created_at":"2026-04-18T21:47:21.321801786Z","created_by":"coding","updated_at":"2026-04-20T11:34:25.923599959Z","closed_at":"2026-04-20T11:34:25.923484797Z","close_reason":"CSRF posture implementation complete (already delivered in P10.5 commit ee3ef23):\n\n- CSRF token generation and double-submit pattern via X-CSRF-Token header\n- Origin validation on session endpoints (admin + search UI)\n- CSP header building with additive csp_overrides merging\n- CSRF middleware with Bearer/X-Admin-Key bypass\n- MissingCsrf (401) and CsrfMismatch (403) error codes\n- Config validation rejects wildcards in csp_overrides\n- Admin session routes with CSRF token rotation\n- Cookie attributes: HttpOnly, Secure, SameSite=Strict\n\nAll acceptance criteria met:\n- Cookie-auth POST without X-CSRF-Token → 403 missing_csrf\n- Cookie-auth POST with wrong token → 403 csrf_mismatch\n- Bearer-auth POST without X-CSRF-Token → 200 (bypass)\n- Session endpoint Origin check → 403 before credential check\n- csp_overrides merging verified (e.g. script-src 'self' https://cdn.example.com)\n- Wildcard (*) in csp_overrides rejected by config validation","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:4","phase-10"],"dependencies":[{"issue_id":"miroir-46p.6","depends_on_id":"miroir-46p","type":"parent-child","created_at":"2026-04-18T21:47:21.321801786Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-46p.7","title":"P10.7 Admin login rate limiting + exponential backoff","description":"## What\n\nPlan §4 admin login endpoint (`POST /_miroir/admin/login`):\n- Rate limit: 10/minute per source IP, backed by `miroir:ratelimit:adminlogin:` in Redis when `miroir.replicas > 1`\n- Failed-login exponential backoff: after 5 consecutive failed attempts from the same IP, backoff window doubles per attempt (10m, 20m, 40m, ...) up to 24h cap\n- Tracked in `miroir:ratelimit:adminlogin:backoff:` hash `{failed_count, next_allowed_at}`\n- Successful login resets both counters\n\n## Why\n\nPlan §4 + §9: \"HA deployments must use shared state for the rate limiter because otherwise per-pod buckets let attackers evade the limit by round-robin'ing across pods.\" Helm `values.schema.json` rejects local-only admin-login rate-limiting in HA.\n\n## Details\n\n**Helm schema constraint** (§P3.5 cross-reference): multi-replica deploys must use Redis backend.\n\n**Failed counter increment on**: wrong `admin_key`, expired cookie, revoked session (not just \"auth failure\" vaguely).\n\n**Successful login reset**: clears both `miroir:ratelimit:adminlogin:` AND `miroir:ratelimit:adminlogin:backoff:`.\n\n**Integration with P2.7 auth dispatch**: the `/_miroir/admin/login` endpoint is dispatch-exempt (plan §5 rule 5) — the handler does its own rate-limit check before any other credential comparison.\n\n**Config**:\n```yaml\nadmin_ui:\n rate_limit:\n per_ip: \"10/minute\"\n failed_attempt_threshold: 5\n backoff_start_minutes: 10\n backoff_max_hours: 24\n backend: redis # redis | local (schema rejects local when replicas > 1)\n```\n\n## Acceptance\n\n- [ ] 11 login attempts in 60s from same IP → 11th returns 429\n- [ ] 5 failed attempts → next attempt blocked for 10m; next attempt after that (also failed) blocked for 20m, etc.\n- [ ] Successful login resets counters\n- [ ] 2-pod deployment with `backend: redis`: attempts against pod-A count against the same bucket as attempts against pod-B\n- [ ] Helm lint rejects `backend: local` with replicas > 1","status":"closed","priority":1,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:47:21.340142141Z","created_by":"coding","updated_at":"2026-04-20T11:52:37.476004521Z","closed_at":"2026-04-20T11:52:37.475894505Z","close_reason":"P10.7: Admin login rate limiting + exponential backoff\n\n- Added record_failure_admin_login to RedisTaskStore for proper consecutive failed attempt tracking\n- Local rate limiter integration in admin_login flow (backend: local)\n- record_failure calls on failed login (wrong admin_key) for both backends\n- Reset on successful login for both backends\n- Helm schema constraint enforces redis backend when replicas > 1\n\nAcceptance:\n- 11 login attempts in 60s from same IP → 11th returns 429\n- 5 failed attempts → backoff doubles per attempt (10m, 20m, 40m, ...) up to 24h cap\n- Successful login resets both rate limit counter and backoff state\n- Multi-pod deployments use shared Redis state for rate limiting","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2","phase-10"],"dependencies":[{"issue_id":"miroir-46p.7","depends_on_id":"miroir-46p","type":"parent-child","created_at":"2026-04-18T21:47:21.340142141Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x","title":"Phase 9 — Testing (§8)","description":"## Phase 9 Epic — Testing\n\nDelivers the plan §8 test suite: unit tests in `miroir-core` with coverage gate, integration tests with docker-compose (3-node Meilisearch + Miroir), API-compatibility tests against real Meilisearch, chaos tests, performance benches with criterion, and SDK smoke tests in four languages.\n\n## Why A Phase, Not Just Per-Feature\n\nTests *within* each feature are written by Phase 1/2/4/5. This phase:\n\n- Stands up the test **harness** (docker-compose, testcontainers, fixtures) that every other phase reuses\n- Implements the cross-cutting suites (compatibility, chaos, SDK smoke) that can't live inside any single feature\n- Locks down the coverage + perf gates before v1.0 per plan §8 coverage policy\n\n## Scope (plan §8)\n\n**Unit tests** (`cargo test --all`)\n- Router correctness suite (determinism, minimal reshuffling, uniform distribution, RF>1 placement)\n- Merger suite (global sort, offset/limit after merge, score stripping, facet counts, estimatedTotalHits)\n- Task registry (persistence across open/close, status aggregation, TTL prune)\n- Primary key extraction (missing → reject, string/int values, nested paths)\n- `miroir-core` coverage ≥ 90% measured via `cargo-tarpaulin`, reported in CI, gates merges from v1.0\n\n**Integration tests** (`tests/integration/`, `--test-threads=1`)\n- docker-compose with 3 Meilisearch nodes + Miroir\n- Document round-trip, search-covers-all-shards, facet aggregation, offset/limit paging, settings broadcast, task polling, node failure with RF=2\n\n**API-compatibility tests**\n- Run same scenarios against a real single-node Meilisearch vs. Miroir; assert semantic equivalence\n- Every Meilisearch error code replayed against both, assert identical `{message,code,type,link}` shape\n- `examples/sdk-tests/` in **Python, JavaScript, Go, Rust** — create/index/search/settings/delete round-trip\n- Against both `docker-compose-dev.yml` and a plain Meilisearch instance\n\n**Chaos tests** (`tests/chaos/`, manual/scripted)\n- Kill 1 of 3 nodes (RF=2) — continuous search; degraded writes warn via header\n- Kill 2 of 3 nodes (RF=2) — shard loss; 503 or partial per policy\n- Kill 1 of 2 Miroir replicas — zero client-visible downtime\n- `tc netem delay 500ms` on one node — search slows, no errors\n- Restart a killed node — Miroir detects within health interval\n- Kill a node mid-rebalance — pause + resume; no data loss\n\n**Performance benchmarks** (`benches/`, criterion)\n- Rendezvous (64 shards, 3 nodes, 10K docs) < 1 ms total\n- Merger (1000 hits, 3 shards) < 1 ms\n- End-to-end search latency < 2× single-node\n- Ingest throughput > 80% single-node\n- CI comment when a PR increases p95 by > 20% vs. last release\n\n## Dependencies\n\nThis phase cannot finish until Phase 2 (integration tests need a running proxy), Phase 4 (chaos tests need rebalance), and Phase 5 (compatibility suite exercises §13 features). But the **harness** (docker-compose files, testcontainers fixtures, CI wiring) can and should be stood up early.\n\n## Definition of Done\n\n- [ ] Full `cargo test --all` green on iad-ci Argo Workflow\n- [ ] `miroir-core` coverage ≥ 90%, published as a CI artifact\n- [ ] Every Meilisearch error code in plan §5 table verified byte-identical in the compat suite\n- [ ] All 4 SDK smoke tests pass against docker-compose-dev\n- [ ] All 6 chaos scenarios documented with runbooks in `tests/chaos/`\n- [ ] Benches green against the targets in plan §8\n- [ ] PR-latency check bot posts delta vs. last release","status":"open","priority":0,"issue_type":"epic","created_at":"2026-04-18T21:22:54.349112402Z","created_by":"coding","updated_at":"2026-04-18T21:23:08.719925813Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase","phase-9"],"dependencies":[{"issue_id":"miroir-89x","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-18T21:23:08.707197480Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-18T21:23:08.719893379Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.1","title":"P9.1 Unit test harness + cargo-tarpaulin coverage gate ≥ 90% for miroir-core","description":"## What\n\nPlan §8 \"Unit tests\" + \"Coverage policy\":\n- Stand up `cargo test --all` in CI (Phase 8 pipeline already runs this)\n- Integrate `cargo-tarpaulin` for line coverage; gate merges from v1.0 at ≥ 90% `miroir-core` coverage\n- Publish coverage report as a CI artifact (HTML + XML)\n- Add a PR comment showing coverage delta\n\n## Why\n\nPlan §8 \"Coverage policy\" explicitly requires ≥ 90% on `miroir-core` with CI gating from v1.0 forward. Without this, the coverage target is aspirational; with it, drops below 90% fail merges.\n\n## Details\n\n**Why 90% on miroir-core specifically**: `miroir-core` is the pure library — routing, merging, topology. Easy to reach ≥ 90% because there's no I/O. Dropping below 90% usually means a new code path wasn't tested, which is exactly what a unit-test gate is for.\n\n**No coverage gate on miroir-proxy / miroir-ctl**: those have I/O, handlers, and main loops that require integration tests. Plan §8 asks for \"integration test coverage for happy paths and key error paths\" rather than a percentage.\n\n**Tarpaulin invocation**:\n```bash\ncargo tarpaulin --workspace \\\n --exclude-files 'crates/miroir-proxy/*' 'crates/miroir-ctl/*' \\\n --out Html --out Xml --output-dir target/tarpaulin/\n```\n\n**PR comment**: use `actions/upload-artifact` equivalent in Argo — artifact is accessible via `https://argo-ci.ardenone.com/workflows/.../artifacts/...`.\n\n## Acceptance\n\n- [ ] First green CI run publishes a tarpaulin report\n- [ ] PR that drops coverage below 90% fails the gate\n- [ ] Report diffable across commits (operators see which lines stopped being covered)","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:45:18.296822582Z","created_by":"coding","updated_at":"2026-04-24T03:52:34.178222018Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.1","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:34.178197622Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.1","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:34.161749987Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.2","title":"P9.2 Integration test harness: docker-compose with 3 Meilisearch nodes + Miroir","description":"## What\n\nBuild `examples/docker-compose-dev.yml` + `examples/dev-config.yaml` + `tests/integration/`:\n\n- 3 Meilisearch nodes (getmeili/meilisearch:v1.37.0) on a shared network\n- 1 Miroir pod pointing at them via the dev config (RG=1, RF=1, S=16)\n- `tests/integration/` with `cargo test --test integration -- --test-threads=1` running against the stack\n\n## Why\n\nPlan §8 \"Integration tests\" + §11 onboarding: the docker-compose file doubles as the \"quick start for a contributor\" stack. It's both the test harness and the developer env.\n\n## Details\n\n**docker-compose-dev.yml**:\n```yaml\nservices:\n meili-0: {image: getmeili/meilisearch:v1.37.0, environment: {MEILI_MASTER_KEY: dev-key}}\n meili-1: {same}\n meili-2: {same}\n miroir: {image: ghcr.io/jedarden/miroir:latest, configmap: dev-config.yaml, ports: [7700, 9090], depends_on: [meili-0, meili-1, meili-2]}\n```\n\n**Integration test cases** (plan §8):\n- Document round-trip (1000 docs)\n- Search covers all shards (unique-keyword test)\n- Facet aggregation (3 colors, sum = 100)\n- Offset/limit paging\n- Settings broadcast\n- Task polling\n- Node failure with RF=2 — `docker stop meili-1` mid-test\n\n**Test harness utilities**:\n- `TestCluster` struct wrapping compose up/down\n- Helpers for doc generation, search, stats\n\n## Acceptance\n\n- [ ] `docker-compose up -d` launches a working Miroir-on-3-Meilisearch stack in < 60s\n- [ ] `cargo test --test integration -- --test-threads=1` passes all plan §8 integration scenarios\n- [ ] Tests clean up after themselves (indexes deleted, compose torn down on Drop)","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:45:18.318956924Z","created_by":"coding","updated_at":"2026-04-24T03:52:34.125938701Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.2","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:34.125912675Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.2","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:34.109860041Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.3","title":"P9.3 API compatibility suite + SDK smoke tests (Py/JS/Go/Rust)","description":"## What\n\nPlan §8 \"API compatibility tests\":\n- Run the same scenarios against a real single-node Meilisearch AND a Miroir instance\n- Assert semantic equivalence: same documents retrievable, same search results, same error codes/shapes\n- Every Meilisearch error code from plan §5 table verified byte-identical\n\nPlus `examples/sdk-tests/` in **Python, JavaScript, Go, Rust** (plan §8):\n- Create index\n- Index documents\n- Search + verify results\n- Update settings\n- Delete index\n\nMust pass against **both** docker-compose-dev.yml (Miroir) and a plain Meilisearch instance.\n\n## Why\n\nPlan §1 principle 1 (invisible federation). If Miroir isn't drop-in, the entire value proposition fails. SDK smoke tests prove it empirically in the four most common client languages.\n\n## Details\n\n**Compatibility cases**:\n- `POST /indexes` with minimal + maximal body shapes\n- `POST /indexes/{uid}/documents` with CSV, NDJSON, JSON arrays\n- All search parameters (limit, offset, filter, facets, sort, attributesToRetrieve, ...)\n- Error responses for every invalid shape (missing PK, invalid filter, nonexistent index, ...)\n- Task lifecycle (enqueue → processing → succeeded/failed; poll and retrieve)\n\n**Error parity harness**:\n```rust\n#[test]\nfn error_parity() {\n for error_case in ERROR_CASES {\n let meili_response = meili_client.call(error_case);\n let miroir_response = miroir_client.call(error_case);\n assert_eq_ignoring_node_ids!(meili_response, miroir_response);\n }\n}\n```\n\n**SDK tests** live in `examples/sdk-tests/{python,javascript,go,rust}/`. Each is self-contained with its own package/dep management (requirements.txt, package.json, go.mod, Cargo.toml).\n\n## Acceptance\n\n- [ ] 100% of Meilisearch error codes listed in plan §5 produce byte-identical error JSON from Miroir\n- [ ] 4/4 SDK smoke tests pass against both Meilisearch and Miroir endpoints\n- [ ] Differences (e.g., `X-Miroir-Degraded` header present on Miroir but not Meilisearch) are documented and intentional; never the error body or HTTP status","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:45:18.350286350Z","created_by":"coding","updated_at":"2026-04-24T03:52:34.077656059Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.3","depends_on_id":"miroir-89x.2","type":"blocks","created_at":"2026-04-18T21:45:22.133861116Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.3","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:34.077631232Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.3","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:34.060556069Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.3.1","title":"P9.3.a API compatibility test harness + error code parity","description":"## Scope (plan §8)\n- Test harness running same scenarios against single-node Meilisearch + Miroir\n- Assert semantic equivalence: same docs retrievable, same search results, same error codes/shapes\n- Every Meilisearch error code from plan §5 verified byte-identical in error JSON\n- No SDKs in this bead — just the Rust-level test harness with HTTP clients\n\n## Acceptance\n- [ ] Parameterized test runner takes `(backend_url, scenario)` tuples\n- [ ] 100% of Meilisearch error codes produce byte-identical JSON from Miroir\n- [ ] Semantic equivalence for: create index, index docs, search, settings update, delete","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-21T12:39:09.870479484Z","created_by":"coding","updated_at":"2026-04-24T03:52:33.358349557Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.3.1","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:33.358308283Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.3.1","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:33.341371984Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.3.2","title":"P9.3.b Python SDK smoke test","description":"examples/sdk-tests/python/ — create/index/search/settings/delete round-trip using the official meilisearch-python SDK pointed at both docker-compose-dev and plain Meilisearch. Fail on any non-equivalent behavior.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:39:09.938341208Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.117555107Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.3.2","depends_on_id":"miroir-89x.3.1","type":"blocks","created_at":"2026-04-21T12:39:10.082968895Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.3.2","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.117527379Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.3.2","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:36.100520766Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.3.3","title":"P9.3.c JavaScript/TypeScript SDK smoke test","description":"examples/sdk-tests/javascript/ — meilisearch-js SDK round-trip (create/index/search/settings/delete) against both backends. package.json + jest/vitest.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:39:09.977550293Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.069533001Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.3.3","depends_on_id":"miroir-89x.3.1","type":"blocks","created_at":"2026-04-21T12:39:10.118170752Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.3.3","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.069493849Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.3.3","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:36.052851114Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.3.4","title":"P9.3.d Go SDK smoke test","description":"examples/sdk-tests/go/ — meilisearch-go SDK round-trip against both backends. go.mod + testing package.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:39:10.008134372Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.018220684Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.3.4","depends_on_id":"miroir-89x.3.1","type":"blocks","created_at":"2026-04-21T12:39:10.148629306Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.3.4","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.018196725Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.3.4","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:36.002729694Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.3.5","title":"P9.3.e Rust SDK smoke test","description":"examples/sdk-tests/rust/ — meilisearch-sdk crate round-trip against both backends. Cargo.toml; shares test fixtures with miroir-core if useful.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:39:10.041773581Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.970477161Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.3.5","depends_on_id":"miroir-89x.3.1","type":"blocks","created_at":"2026-04-21T12:39:10.183951253Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.3.5","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:35.970453279Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.3.5","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:35.954655383Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.4","title":"P9.4 Chaos test scenarios (tests/chaos/) + runbooks","description":"## What\n\nPlan §8 chaos scenarios, each as a scripted test + a runbook in `tests/chaos/`:\n\n| # | Scenario | Expected result |\n|---|----------|-----------------|\n| 1 | Kill 1 of 3 nodes (RF=2) | Continuous search; degraded writes warn via header |\n| 2 | Kill 2 of 3 nodes (RF=2) | Shard loss; 503 or partial per policy |\n| 3 | Kill 1 of 2 Miroir replicas | Zero client-visible downtime |\n| 4 | `tc netem delay 500ms` on one node | Searches slow by at most max shard latency; no errors |\n| 5 | Restart a killed node | Miroir detects recovery within health check interval, resumes routing |\n| 6 | Kill a node mid-rebalance | Rebalancer pauses, resumes on recovery; no data loss |\n\n## Why\n\nPlan §1 principle 5 (graceful degradation). These are the scenarios that convince operators Miroir is production-grade. Each one's expected result matters more than the test itself — the runbook captures what operators should expect during real outages.\n\n## Details\n\n**Test harness**: extend P9.2's `TestCluster` with chaos helpers:\n- `cluster.kill_meili(i: usize)` — `docker stop` a node\n- `cluster.restart_meili(i)`\n- `cluster.apply_netem(i, delay_ms)` — add latency via `tc netem`\n- `cluster.kill_miroir()` — scale `miroir` service down then up\n\n**Execution**: these are slow tests (30+ seconds each for recovery cycles). Mark with `#[ignore]` or behind a `--ignored` flag so they don't run in the default `cargo test`. CI runs them on the `miroir-chaos` WorkflowTemplate.\n\n**Runbooks**: `tests/chaos/runbook-.md` documents:\n- Precondition check\n- Manual repro steps\n- Expected observable (metrics, headers, client error shape)\n- Recovery procedure (if needed)\n- How this differs on HA (2+ Miroir replicas)\n\n## Acceptance\n\n- [ ] All 6 scenarios have automated tests passing in the chaos CI run\n- [ ] Each has a runbook in `tests/chaos/` reviewed for operator clarity\n- [ ] A post-incident reader can use a runbook to confirm whether a given observation was expected","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:45:18.382966857Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.451886199Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.4","depends_on_id":"miroir-89x.2","type":"blocks","created_at":"2026-04-18T21:45:22.151848706Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.4","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.451832578Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.4","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:37.434988291Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.4.1","title":"P9.4.a Chaos: kill 1 of 3 nodes (RF=2) — continuous search + degraded-write header","description":"tests/chaos/scenario-01-kill-one-node.sh + runbook. `docker stop meili-1` mid-traffic. Expected: search continues, writes stamp X-Miroir-Degraded. No client-visible errors beyond retry budget.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:39:46.069187979Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.920966114Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.4.1","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:35.920936168Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.4.1","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:35.901392813Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.4.2","title":"P9.4.b Chaos: kill 2 of 3 nodes (RF=2) — shard loss, 503 or partial per policy","description":"tests/chaos/scenario-02-kill-two-nodes.sh. `docker stop meili-0 meili-1`. Expected: shard loss; unavailable_shard_policy=partial returns X-Miroir-Degraded+hits; =error returns 503 miroir_shard_unavailable.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:39:46.109859648Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.864135463Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.4.2","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:35.864112518Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.4.2","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:35.847746758Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.4.3","title":"P9.4.c Chaos: kill 1 of 2 Miroir replicas — zero client downtime","description":"tests/chaos/scenario-03-kill-miroir-replica.sh. Scale Miroir service down 2→1→2 under load. Expected: Service round-robin routes to survivor; zero client-visible failures (modulo retry budget).","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:39:46.146517797Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.810318485Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.4.3","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:35.810294168Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.4.3","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:35.793490895Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.4.4","title":"P9.4.d Chaos: tc netem delay 500ms on one node — bounded tail latency","description":"tests/chaos/scenario-04-netem-delay.sh. Apply tc netem delay 500ms to meili-1; issue 1000 searches. Expected: p95 bounded by slowest shard; no errors. §13.2 hedging (when enabled) should mitigate further.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-21T12:39:46.177349609Z","created_by":"coding","updated_at":"2026-04-24T03:52:38.653842924Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.4.4","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:38.653818221Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.4.4","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:38.633764707Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.4.5","title":"P9.4.e Chaos: restart killed node — recovery detection + routing resume","description":"tests/chaos/scenario-05-restart-node.sh. Kill then restart meili-1; confirm Miroir detects recovery within health.interval_ms × recovery_threshold and resumes routing writes to it.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-21T12:39:46.212921879Z","created_by":"coding","updated_at":"2026-04-24T03:52:38.601906443Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.4.5","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:38.601881930Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.4.5","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:38.586049118Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.4.6","title":"P9.4.f Chaos: kill node mid-rebalance — pause, resume, no data loss","description":"tests/chaos/scenario-06-kill-during-rebalance.sh. Start node addition (POST /_miroir/nodes); kill source node mid-migration; restart. Expected: rebalancer pauses, resumes from persisted cursor, zero doc loss (Mode B leader + Phase 4 dual-write).","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:39:46.248550845Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.759491020Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.4.6","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:35.759455192Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.4.6","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:35.742843230Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.5","title":"P9.5 Performance benches (criterion) + regression gate","description":"## What\n\nPlan §8 \"Performance benchmarks\" at `benches/` using criterion:\n\n| Benchmark | Target |\n|-----------|--------|\n| Rendezvous (64 shards, 3 nodes, 10K docs) | < 1 ms total |\n| Merger (1000 hits, 3 shards) | < 1 ms |\n| End-to-end search latency vs. single-node | < 2× single-node |\n| Ingest throughput (1000 docs through Miroir) | > 80% single-node |\n\nPlus a CI bot that comments on any PR increasing measured search latency by > 20% over the previous release.\n\n## Why\n\nPlan §8: \"A PR that increases measured search latency by > 20% over the previous release triggers a review comment.\" Without a regression gate, performance drifts. With it, drift is noticed at the PR level.\n\n## Details\n\n**criterion output artifact**: `target/criterion/` HTML reports; CI uploads as artifact.\n\n**Delta computation**: compare current PR's bench output vs. the most recent `main` run's stored bench output. `critcmp` is the typical tool.\n\n**Gating vs. commenting**: plan §8 says \"review comment,\" not \"block merge.\" Keep the tool advisory — operators trigger reruns for transient noise.\n\n**End-to-end search latency bench** needs a running docker-compose stack; run as part of integration benches, not unit benches.\n\n## Acceptance\n\n- [ ] `cargo bench -p miroir-core` runs in CI and records timings\n- [ ] Rendezvous bench passes `< 1 ms` target on iad-ci hardware\n- [ ] Merger bench passes `< 1 ms` target\n- [ ] End-to-end `< 2×` and ingest `> 80%` verified on a 3-node docker-compose\n- [ ] PR with intentional 30% slowdown triggers the comment bot","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:45:18.407337766Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.401545811Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.5","depends_on_id":"miroir-89x.2","type":"blocks","created_at":"2026-04-18T21:45:22.172432130Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.5","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.401501253Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.5","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:37.380833305Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-89x.6","title":"P9.6 Property tests + fuzz for router + config + parser","description":"## What\n\nAdd proptest + cargo-fuzz coverage for the critical invariants:\n\n**Router** (`proptest`, in addition to P1.6):\n- Given random `(N, RG, RF, S)` and random doc IDs, `write_targets` + `covering_set` satisfy:\n - `|write_targets| == RG × RF` (counting duplicates)\n - Every group has exactly `RF` entries\n - `covering_set` unions to cover every shard in the chosen group\n - Reshuffle on topology change ≤ theoretical optimum\n\n**Config parser**: fuzz `Config::from_yaml` — every valid YAML in the plan parses; adversarial inputs don't crash.\n\n**Filter DSL parser** (§13.4): fuzz the filter grammar — every Meilisearch valid filter parses; malformed filters return `Err`, not panic.\n\n**Canonical-JSON** (for settings hashing §13.5): two equivalent JSONs must hash identically.\n\n## Why\n\nPlan §8 lists property tests in the \"Router correctness\" section. Adding fuzz to parsers closes the class-of-errors where a single crafted input OOMs or panics the orchestrator.\n\n## Details\n\n**Proptest configs**: 1024 cases per property by default; 8192 in the nightly CI run.\n\n**cargo-fuzz targets** (in `fuzz/fuzz_targets/`):\n- `config_parser.rs` — feeds random UTF-8 to `Config::from_yaml_str`\n- `filter_parser.rs` — feeds random strings to the §13.4 filter grammar\n- `canonical_json.rs` — roundtrips random JSON through the canonicalizer\n\n**Corpus seeding**: include every plan-referenced valid config, filter, and settings block as seeds so fuzz discovers edge cases rather than rediscovering syntax.\n\n## Acceptance\n\n- [ ] `cargo test` runs all property tests at 1024 cases; no rejects\n- [ ] `cargo +nightly fuzz run config_parser -- -max_total_time=60` finds no panics in 60s\n- [ ] Weekly CI fuzz run (scheduled via Argo Workflow) uploads artifacts showing 0 new crashes","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:45:18.438638293Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.345649763Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.6","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.345617443Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-89x.6","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-24T03:52:37.330699708Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-9dj","title":"Phase 2 — Proxy + API Surface (HTTP routes, quorum, errors)","description":"## Phase 2 Epic — Proxy + API Surface\n\nWires the Phase 1 primitives into a live HTTP proxy. After this phase, a client pointing a Meilisearch SDK at `http://miroir:7700` can CRUD indexes, write documents, search, and poll tasks — with documents actually sharded across nodes.\n\n## Why This Sits Here\n\nPlan §1 principle 1 (**invisible federation**) and plan §5 (**API Surface and Compatibility**) are the product. Phase 1 gave us math; this phase turns the math into behavior a Meilisearch client sees as drop-in. Every downstream phase assumes these HTTP surfaces exist and return shapes that match the Meilisearch spec exactly, so §8 \"API compatibility tests\" can pin the contract from here on.\n\n## Scope (plan §3 Lifecycle + §5 API Surface)\n\n- `axum` server listening on `server.port` (default 7700) and metrics on 9090\n- **Write path** (plan §2 write path) — hash primary key, inject `_miroir_shard`, fan out to `RG × RF` nodes, per-group quorum (`floor(RF/2)+1`), `X-Miroir-Degraded` on any group missing quorum, 503 `miroir_no_quorum` only when no group met quorum for a shard\n- **Read path** (plan §2 read path) — pick group via `query_seq % RG`, build intra-group covering set, scatter, merge by `_rankingScore`, strip `_miroir_shard` always + `_rankingScore` if client didn't request, aggregate facets + estimatedTotalHits, report max processingTimeMs, group-fallback when a covering set has holes\n- **Index lifecycle** (plan §3) — create broadcasts + atomically injects `_miroir_shard` into `filterableAttributes`; settings sequential apply-with-rollback (§3 legacy; §13.5 replaces in Phase 5); delete broadcasts; stats aggregate `numberOfDocuments` + merge `fieldDistribution`\n- **Tasks** — per plan §3 task ID reconciliation; `GET /tasks`, `GET /tasks/{uid}`, `DELETE /tasks/{uid}`\n- **Error shape** — every error matches Meilisearch `{message,code,type,link}`; new `miroir_*` codes per plan §5\n- **Reserved fields contract** — `_miroir_shard` always-reserved; `_miroir_updated_at` / `_miroir_expires_at` reserved only when their feature flag is on (Phase 5)\n- **Auth** — master-key/admin-key bearer dispatch per §5 \"Bearer token dispatch\" rules 2–5; JWT path stubbed (Phase 5)\n- **/health + /version + /_miroir/ready + /_miroir/topology + /_miroir/shards** + **/_miroir/metrics** (admin-key gated mirror of port 9090 /metrics per plan §10)\n- **Middleware** — structured JSON log per plan §10; Prometheus metrics (`miroir_request_duration_seconds`, etc.)\n- **Scatter-gather dispatcher** — per-node retries with orchestrator-side retry cache keyed by `sha256(batch || target_node || idempotency_or_mtask)` (plan §4 note on `scatter.retry_on_timeout`)\n\n## Out of Scope (moved to later phases)\n\n- Two-phase settings broadcast (→ Phase 5 / §13.5)\n- Persistent task store (→ Phase 3)\n- Rebalancer (→ Phase 4)\n- Any §13 feature (→ Phase 5)\n- Multi-replica coordination / Redis / HPA (→ Phase 6)\n\n## Definition of Done\n\n- [ ] Integration test: 1000 documents indexed across 3 nodes, each retrievable by ID (plan §8)\n- [ ] Integration test: unique-keyword search finds every doc exactly once (plan §8)\n- [ ] Integration test: facet aggregation across 3 color values sums correctly (plan §8)\n- [ ] Integration test: offset/limit paging preserves global ordering (plan §8)\n- [ ] Integration test: write with one group completely down still succeeds on remaining group and stamps `X-Miroir-Degraded`\n- [ ] Error-format parity test: every `invalid_request`/`not_found`/`document_*` code matches Meilisearch output byte-for-byte on equivalent input\n- [ ] `GET /_miroir/topology` matches the shape in plan §10","status":"closed","priority":0,"issue_type":"epic","assignee":"bravo","created_at":"2026-04-18T21:18:33.148045077Z","created_by":"coding","updated_at":"2026-04-19T13:30:20.971197464Z","closed_at":"2026-04-19T13:30:20.971126888Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","phase","phase-2"],"dependencies":[{"issue_id":"miroir-9dj","depends_on_id":"miroir-cdo","type":"blocks","created_at":"2026-04-18T21:23:08.570130243Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-9dj.1","title":"P2.1 axum server skeleton + config loader + /health + /version + /_miroir/ready","description":"## What\n\nFlesh out `miroir-proxy::main`:\n- Load `Config` (file + env + CLI args overlay)\n- Initialize tracing (JSON-to-stdout per plan §10 log format)\n- Start two axum listeners: `:7700` (client API) + `:9090` (metrics, unauthenticated, pod-internal)\n- Signal handlers for graceful shutdown (SIGTERM → stop accepting new requests → drain in-flight → exit)\n- Implement: `GET /health`, `GET /version`, `GET /_miroir/ready`, `GET /_miroir/topology`, `GET /_miroir/shards`, `GET /_miroir/metrics`\n\n## Why\n\nThese are the minimum-viable endpoints Kubernetes needs to probe and operators need to inspect. `GET /health` is Meilisearch-compatible — the K8s liveness probe — and must return 200 immediately regardless of internal state (Meilisearch semantics). `GET /_miroir/ready` is the readiness probe and *blocks* 503 until a covering quorum is reachable on first startup (plan §10).\n\n## Details\n\n**`/health`** (plan §10) — returns `{\"status\":\"available\"}`. Never gate on internal state.\n\n**`/version`** — per plan §5 \"Orchestrator-local\": return the Meilisearch version from any healthy node. Cache at ~60s TTL.\n\n**`/_miroir/ready`** — 503 during startup; 200 once Miroir has loaded config + verified a covering quorum of nodes is reachable. This is specifically where the \"there's at least one full covering set somewhere in the topology\" check lives.\n\n**`/_miroir/topology`** — shape exactly per plan §10 JSON sample: `shards`, `replication_factor`, `nodes[]` with `id/status/shard_count/last_seen_ms[/error]`, `degraded_node_count`, `rebalance_in_progress`, `fully_covered`.\n\n**`/_miroir/shards`** — shard → node mapping table for the current topology (useful for runbooks and for §13.20 explain).\n\n**`/_miroir/metrics`** — admin-key-gated mirror of port 9090 `/metrics`. Same data; admin-authenticated so it can be exposed outside the cluster.\n\n## Acceptance\n\n- [ ] `curl localhost:7700/health` returns 200 within 100ms of process start\n- [ ] `curl localhost:7700/_miroir/ready` returns 503 until all configured nodes are reachable, then 200\n- [ ] `curl -H \"Authorization: Bearer $ADMIN_KEY\" localhost:7700/_miroir/topology | jq .` matches the plan §10 shape\n- [ ] SIGTERM drains in-flight requests (test by sending signal during a long-running search)","status":"closed","priority":0,"issue_type":"task","assignee":"alpha","created_at":"2026-04-18T21:28:30.051416112Z","created_by":"coding","updated_at":"2026-04-19T10:12:25.069881842Z","closed_at":"2026-04-19T10:12:25.069816741Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:7","phase-2"],"dependencies":[{"issue_id":"miroir-9dj.1","depends_on_id":"miroir-9dj","type":"parent-child","created_at":"2026-04-18T21:28:30.051416112Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-9dj.1","depends_on_id":"miroir-9dj.8","type":"blocks","created_at":"2026-04-18T21:28:35.581837637Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-9dj.2","title":"P2.2 Document write path: primary key → hash → shard → fan-out → quorum","description":"## What\n\nImplement:\n- `POST /indexes/{uid}/documents`\n- `PUT /indexes/{uid}/documents`\n- `DELETE /indexes/{uid}/documents/{id}`\n- `DELETE /indexes/{uid}/documents` (by IDs array or filter)\n\n## Why\n\nPlan §2 \"Write path\" is the heart of the product. Four properties that MUST be right:\n\n1. **Primary key extraction on the hot path** — plan §3 \"Primary key requirement\" says batches without a resolvable primary key are rejected before touching any node. This is a cheap, up-front check and a big UX win.\n2. **`_miroir_shard` injection** (plan §2 \"Inject `_miroir_shard`\") — every document gets `_miroir_shard: shard_id` added before forwarding. Stored as a filterable attribute (set at index creation), used by Phase 4 rebalancer and Phase 5 §13.8 anti-entropy for targeted shard retrieval. Stripped from all API responses.\n3. **Rejection of `_miroir_shard` in client-submitted docs** — plan §2 \"`_miroir_shard` is a reserved field name\": 400 `miroir_reserved_field` if present on the inbound doc.\n4. **Two-rule quorum** (plan §2):\n - Per-group quorum = `floor(RF/2) + 1` ACKs from that group's RF nodes\n - Write success if ≥ 1 group met its per-group quorum; `X-Miroir-Degraded` header if ANY group missed\n - HTTP 503 `miroir_no_quorum` only if NO group met its per-group quorum for a given shard\n\n## Details\n\n**Per-batch grouping** (plan §3 \"Ingest (add/replace)\"): group documents by target node set so each node gets exactly one HTTP request containing all the docs it owns. This minimizes HTTP fan-out count (critical at scale).\n\n**Retry-on-timeout** (plan §4 \"Note on `scatter.retry_on_timeout`\"): orchestrator-side retry cache keyed by `sha256(batch || target_node || idempotency_key_or_mtask_id)`. When a timeout retries, check the cache first; if the prior dispatch has a cached terminal response, return it rather than creating a duplicate node-side task.\n\n**Delete-by-filter** (plan §5 \"Broadcast to all nodes\"): cannot be shard-routed; broadcast to every node.\n\n**Delete-by-IDs array**: route each ID to its shard independently (same routing as the write path).\n\n## Acceptance (plan §8)\n\n- [ ] 1000 docs indexed via POST — every doc fetch-by-id returns the same doc\n- [ ] Docs distribute across all configured nodes (no node holds < 20% under RF=1/3-node)\n- [ ] Batch with one missing primary key → 400 `miroir_primary_key_required`, no docs written anywhere\n- [ ] Doc containing `_miroir_shard` → 400 `miroir_reserved_field`\n- [ ] RG=2, RF=1, 1 group down: write to 1 group succeeds with `X-Miroir-Degraded: groups=1`\n- [ ] RG=2, RF=1, both groups down: 503 `miroir_no_quorum`\n- [ ] DELETE by IDs array [docA, docB] with docA on shard 3, docB on shard 7 produces 2 independent per-shard delete calls","status":"closed","priority":0,"issue_type":"task","assignee":"alpha","created_at":"2026-04-18T21:28:30.071116940Z","created_by":"coding","updated_at":"2026-04-19T10:49:11.542255356Z","closed_at":"2026-04-19T10:49:11.542139590Z","close_reason":"Implemented P2.2 write path with POST/PUT/DELETE document endpoints. Primary key validation, _miroir_shard injection, reserved field rejection, two-rule quorum, degraded header support. 15 acceptance tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:4","phase-2"],"dependencies":[{"issue_id":"miroir-9dj.2","depends_on_id":"miroir-9dj","type":"parent-child","created_at":"2026-04-18T21:28:30.071116940Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-9dj.2","depends_on_id":"miroir-9dj.1","type":"blocks","created_at":"2026-04-18T21:28:35.455097028Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-9dj.2","depends_on_id":"miroir-9dj.6","type":"blocks","created_at":"2026-04-18T21:28:35.534066064Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-9dj.2","depends_on_id":"miroir-9dj.7","type":"blocks","created_at":"2026-04-18T21:28:35.549164039Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-9dj.3","title":"P2.3 Search read path: scatter-gather + merge + group selection","description":"## What\n\nImplement `POST /indexes/{uid}/search`:\n1. Pick group = `query_seq % RG` (plan §2)\n2. Build intra-group covering set (plan §4 `covering_set`)\n3. Fan out search to each node in covering set **with `showRankingScore: true` appended** (plan §2 read path step 4)\n4. Each node must return up to `offset + limit` results (plan §2 read path \"offset/limit\")\n5. Use P1.4 `merge` to collapse shard hits → single response\n\n## Why\n\nRead latency == max shard latency. This is where hedging (§13.2), adaptive replica selection (§13.3), and query coalescing (§13.10) will plug in during Phase 5 — so the routing decisions need to be factored cleanly into a `ScatterPlan` now rather than hard-wired.\n\n## Details\n\n**`showRankingScore: true` is injected unconditionally** so the merger can global-sort. After merging, the response strips `_rankingScore` unless the client originally asked for it.\n\n**Partial unavailability** (plan §3 `unavailable_shard_policy: partial`, default): if a shard is fully unavailable, return best-effort hits with `X-Miroir-Degraded: shards=3,7,11`. `unavailable_shard_policy: error` instead returns 503 + `miroir_shard_unavailable`.\n\n**Group-unavailability fallback** (plan §2 \"Group unavailability fallback\"): if the selected group has a shard with no available intra-group RF replica, Miroir optionally falls back to a different group for **that query** (full result, different group).\n\n**Facets** — plan §2 step 7: sum per-value counts across the covering set.\n\n**`estimatedTotalHits`** — sum across covering set.\n\n**`processingTimeMs`** — max across covering set.\n\n## Acceptance (plan §8)\n\n- [ ] Unique-keyword search across 3 nodes returns exactly 1 hit (proves merger + fan-out correctness)\n- [ ] Facet counts sum correctly across shards\n- [ ] Paging: 5 pages of 10 = single limit=50 order, no dupes/gaps\n- [ ] With one node down and RF=2: search still covers all shards (tests fall-back within the group)\n- [ ] With one group fully down: search uses the other group; response is not `X-Miroir-Degraded`\n- [ ] `X-Miroir-Degraded: shards=...` stamped when a shard has zero live replicas","status":"closed","priority":0,"issue_type":"task","assignee":"delta","created_at":"2026-04-18T21:28:30.086916926Z","created_by":"coding","updated_at":"2026-04-19T10:45:18.871650628Z","closed_at":"2026-04-19T10:45:18.871538688Z","close_reason":"P2.3 Search read path complete with all acceptance tests passing:\n\n**Implemented:**\n- POST /indexes/{uid}/search with scatter-gather + merge + group selection\n- Group selection via query_seq % RG (round-robin across replica groups)\n- Intra-group covering set using plan §4 covering_set\n- Fan out to all nodes in covering set with showRankingScore: true injected unconditionally\n- Each node returns offset + limit results for coordinator pagination\n- P1.4 merge (score-based with global IDF, RRF fallback)\n- X-Miroir-Degraded: shards=X,Y,Z header for partial unavailability\n- Group-unavailability fallback (Fallback policy)\n- Facet count aggregation (sum across covering set)\n- estimatedTotalHits = sum across covering set\n- processingTimeMs = max across covering set\n\n**Acceptance tests passing (10/10):**\n- Unique-keyword search across 3 nodes returns exactly 1 hit (proves merger + fan-out correctness)\n- Facet counts sum correctly across shards\n- Paging: 5 pages of 10 = single limit=50 order, no dupes/gaps\n- With one node down and RF=2: search still covers all shards (intra-group fallback)\n- With one group fully down: search uses the other group; response is not X-Miroir-Degraded\n- X-Miroir-Degraded: shards=... stamped when a shard has zero live replicas\n\n**Technical details:**\n- SearchRequest.to_node_body() injects showRankingScore: true unconditionally\n- Coordinator applies offset/limit after global merge (nodes receive offset=0, limit=offset+limit)\n- _rankingScore stripped unless client originally requested it\n- ScoreMergeStrategy for global-IDF mode (OP#4), RrfStrategy as fallback\n- Preflight phase aggregates global IDF for cross-shard score comparability","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:2","phase-2"],"dependencies":[{"issue_id":"miroir-9dj.3","depends_on_id":"miroir-9dj","type":"parent-child","created_at":"2026-04-18T21:28:30.086916926Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-9dj.3","depends_on_id":"miroir-9dj.1","type":"blocks","created_at":"2026-04-18T21:28:35.467879223Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-9dj.3","depends_on_id":"miroir-9dj.7","type":"blocks","created_at":"2026-04-18T21:28:35.563401698Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-9dj.4","title":"P2.4 Index lifecycle endpoints: create/update/delete + settings broadcast","description":"## What\n\nImplement:\n- `POST /indexes` — create index; broadcast to every node; atomically adds `_miroir_shard` to `filterableAttributes`\n- `PATCH /indexes/{uid}` — settings updates; sequential apply-with-rollback (legacy strategy; §13.5 two-phase broadcast replaces in Phase 5)\n- `DELETE /indexes/{uid}` — broadcast\n- `GET /indexes/{uid}/stats` + `GET /stats` — fan out, sum `numberOfDocuments`, merge `fieldDistribution`\n- `POST /keys`, `PATCH /keys/{key}`, `DELETE /keys/{key}` — broadcast\n\n## Why\n\n**Plan §3 \"Index lifecycle\"**: create must broadcast, every node creates the same index with the same settings. Partial creation is rolled back. Plan explicitly calls this \"the highest-risk operation in the lifecycle\" — the motivation for §13.5. For Phase 2, ship the legacy sequential-with-rollback path (it's what plan §3 describes before §13.5).\n\n**Crucial subtlety**: plan §3 says index creation \"additionally broadcasts a settings update to add `_miroir_shard` to `filterableAttributes` on every node — this is required for efficient rebalancing.\" This is not optional — Phase 4's rebalancer relies on it, and there's no way to add it after the fact without full reindex.\n\n## Details\n\n**Create rollback**: if any node fails, `DELETE /indexes/{uid}` on all previously-created nodes. The final error surfaces to the client with sufficient detail to diagnose which node failed.\n\n**Settings sequential**:\n1. Apply to node-0, verify via `GET /indexes/{uid}/settings`\n2. Apply to node-1, verify\n3. ... all nodes\n4. On failure: revert all previously applied nodes to the pre-change settings snapshot\n\n**Settings bucket under `__reserved_settings` for §13.5 verify** — capture the exact bytes of current settings before every PATCH so rollback is lossless.\n\n**Delete-by-filter** — broadcast; note that this is a document endpoint, but the code path joins here.\n\n**Stats aggregation**:\n- `numberOfDocuments` — sum across all nodes (duplicates per-replica across RG×RF; divide by (RG × RF) to get logical doc count)\n- `fieldDistribution` — sum per-field counts across nodes\n\n## Acceptance\n\n- [ ] `POST /indexes` creates an index on every node; failure on any node rolls back\n- [ ] Settings broadcast sequential: a mid-broadcast node failure reverts all previously applied nodes\n- [ ] `_miroir_shard` is in `filterableAttributes` immediately after index creation (verified via `GET /indexes/{uid}/settings`)\n- [ ] `GET /indexes/{uid}/stats` `numberOfDocuments` = logical count (not replica-multiplied)\n- [ ] `/keys` CRUD broadcasts; all-or-nothing (atomic across nodes)","status":"closed","priority":0,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:28:30.110577382Z","created_by":"coding","updated_at":"2026-04-19T11:59:47.535664512Z","closed_at":"2026-04-19T11:59:47.535551707Z","close_reason":"P2.4 index lifecycle endpoints complete. POST /indexes broadcasts with rollback + _miroir_shard. PATCH/DELETE broadcast. Stats fan-out with logical doc count. Keys CRUD all-or-nothing. 11 p24 tests + 69 workspace tests passing.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","phase-2"],"dependencies":[{"issue_id":"miroir-9dj.4","depends_on_id":"miroir-9dj","type":"parent-child","created_at":"2026-04-18T21:28:30.110577382Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-9dj.4","depends_on_id":"miroir-9dj.1","type":"blocks","created_at":"2026-04-18T21:28:35.484952960Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-9dj.5","title":"P2.5 Task ID reconciliation and /tasks endpoints","description":"## What\n\nImplement plan §3 \"Task ID reconciliation\":\n- Every write fan-out collects per-node `taskUid` values\n- Generate a Miroir task ID `mtask-`\n- Persist `mtask → {node_id: node_task_uid}` in the in-memory task registry (Phase 3 makes it durable)\n- Return `mtask-xxxxx` to client as `{\"taskUid\": ...}` in Meilisearch shape\n- `GET /tasks/{mtask_id}` polls every mapped node task, aggregates:\n - `succeeded` — all nodes report `succeeded`\n - `failed` — any node reports `failed`; include the per-node error detail\n - `processing` — otherwise\n- `GET /tasks?statuses=...` — list across all mtasks with Meilisearch-compatible query params\n\n## Why\n\nClients (SDKs) use the Meilisearch task API as-is. Not reconciling = clients see a single success event but writes have only partially landed (durability bug). Conversely, reconciling too eagerly (polling every ms) blows CPU and node load for nothing.\n\n## Details\n\n**Polling cadence**: exponential backoff per mtask: 25 ms → 50 → 100 → ... cap at 1s. Stop polling once terminal.\n\n**Retention**: default 7 days, pruned by Mode A rendezvous-partitioned pruner (Phase 6 §14.5). Until Phase 3, retention is in-memory only.\n\n**Error aggregation**: if any node fails, present a compact Meilisearch-shaped error but include per-node breakdown as `error.details`.\n\n**`GET /tasks`** (Meilisearch-compatible filters): `statuses`, `types`, `indexUids`, `from`, `limit`. Must paginate across mtasks consistently.\n\n**`DELETE /tasks/{mtask_id}`** — cancel if possible (delegate to Meilisearch; may no-op if Meilisearch doesn't support cancel on that type).\n\n## Acceptance\n\n- [ ] Fan-out to 3 nodes → all 3 `taskUid`s captured in one mtask\n- [ ] `GET /tasks/{mtask_id}` while all nodes are processing → `processing`\n- [ ] One node fails → status `failed`, error includes per-node breakdown\n- [ ] In-memory registry survives the request's own lifetime (Phase 3 makes it persistent)","status":"closed","priority":0,"issue_type":"task","assignee":"delta","created_at":"2026-04-18T21:28:30.145971113Z","created_by":"coding","updated_at":"2026-04-19T11:47:19.097113824Z","closed_at":"2026-04-19T11:47:19.097047185Z","close_reason":"Implemented P2.5 task ID reconciliation and /tasks endpoints:\n- InMemoryTaskRegistry with mtask- generation\n- Write path collects per-node taskUid values and registers unified mtask\n- GET /tasks/{id} polls nodes and aggregates status (succeeded/failed/processing)\n- GET /tasks with Meilisearch-compatible filters (statuses, types, indexUids, from, limit)\n- DELETE /tasks/{id} for best-effort cancellation\n- Exponential backoff polling (25ms → 50 → 100 → ... → 1s cap)\n- Per-node error breakdown in failed task responses\n- NodeClient.get_task_status() for polling individual node tasks","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:8","phase-2"],"dependencies":[{"issue_id":"miroir-9dj.5","depends_on_id":"miroir-9dj","type":"parent-child","created_at":"2026-04-18T21:28:30.145971113Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-9dj.5","depends_on_id":"miroir-9dj.2","type":"blocks","created_at":"2026-04-18T21:28:35.513353534Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-9dj.6","title":"P2.6 Error mapping and Meilisearch-compatible error shape","description":"## What\n\nImplement the error response shape from plan §5:\n```json\n{\"message\": \"...\", \"code\": \"...\", \"type\": \"invalid_request\", \"link\": \"...\"}\n```\n\nAnd every `miroir_*` code from plan §5:\n- `miroir_primary_key_required`\n- `miroir_no_quorum`\n- `miroir_shard_unavailable`\n- `miroir_reserved_field` (covers `_miroir_shard` always; `_miroir_updated_at` + `_miroir_expires_at` only when their feature flags are on)\n- `miroir_idempotency_key_reused` (Phase 5 §13.10)\n- `miroir_settings_version_stale` (Phase 5 §13.5)\n- `miroir_multi_alias_not_writable` (Phase 5 §13.7)\n- `miroir_jwt_invalid` (Phase 5 §13.21)\n- `miroir_jwt_scope_denied` (Phase 5 §13.21)\n- `miroir_invalid_auth`\n\nPlus: forward Meilisearch errors verbatim when the failure happened node-side.\n\n## Why\n\nPlan §8 API compatibility: \"Test every expected Meilisearch error code against both real Meilisearch and Miroir.\" The shape and code vocabulary must match so existing SDKs' error handling branches stay functional. Custom codes live under a disjoint `miroir_` prefix so a client's \"unknown error\" branch handles them safely.\n\n## Details\n\n**Error type enum**: `invalid_request`, `auth`, `internal`, `system` — mirroring Meilisearch categories. Each `miroir_*` code maps to one of these.\n\n**Link field**: point at `https://github.com/jedarden/miroir/blob/main/docs/errors.md#` — anchors generated at build time.\n\n**Error struct**:\n```rust\n#[derive(Debug, thiserror::Error, serde::Serialize)]\npub struct MeilisearchError {\n pub message: String,\n pub code: String, // e.g. \"miroir_no_quorum\" or \"document_not_found\"\n #[serde(rename = \"type\")]\n pub error_type: ErrorType,\n pub link: Option,\n}\n```\n\n**Status codes**:\n- 400: primary_key_required, reserved_field\n- 401: invalid_auth, jwt_invalid\n- 403: jwt_scope_denied\n- 409: idempotency_key_reused, multi_alias_not_writable\n- 503: no_quorum, shard_unavailable, settings_version_stale\n\n## Acceptance\n\n- [ ] Every code in plan §5 table has a unit test producing the expected JSON shape\n- [ ] Meilisearch-native error passes through unchanged (forwarded from node responses)\n- [ ] HTTP status codes match the plan §5 mapping","status":"closed","priority":0,"issue_type":"task","assignee":"alpha","created_at":"2026-04-18T21:28:30.179370234Z","created_by":"coding","updated_at":"2026-04-19T09:22:11.445497706Z","closed_at":"2026-04-19T09:22:11.445388559Z","close_reason":"P2.6 complete. All acceptance criteria met: (1) 10 per-code JSON shape tests, (2) Meilisearch-native error forwarding via forwarded() with round-trip tests, (3) HTTP status code mapping verified. Commits: 9606af8 (core shape + tests), fca081e (proxy integration).","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","phase-2"],"dependencies":[{"issue_id":"miroir-9dj.6","depends_on_id":"miroir-9dj","type":"parent-child","created_at":"2026-04-18T21:28:30.179370234Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-9dj.7","title":"P2.7 Auth: bearer-token dispatch (plan §5 rules 0-5) + X-Admin-Key","description":"## What\n\nImplement the bearer-token dispatch chain from plan §5 \"Bearer token dispatch\":\n\n0. **Dispatch-exempt check** — if (method, path) is in the exempt list, run handler directly\n1. **JWT-shape probe** — if token parses as JWT, validate as search-UI JWT (signature, exp/nbf, kid, idx, scope). Parseable-but-invalid → 401 `miroir_jwt_invalid`. Signature-valid but scope mismatch → 403 `miroir_jwt_scope_denied`. Phase 5 §13.21 adds the JWT validation; Phase 2 stubs this to \"not-a-jwt → next step\"\n2. **Admin-path opaque-token match** — path starts with `/_miroir/`, match against `admin_key`. Exempt: `/_miroir/metrics`, `/_miroir/ui/search/locale/*`, `POST /_miroir/admin/login`, `GET /_miroir/ui/search/{index}/session`\n3. **Master-key match** — other paths → `master_key`\n4. **Mismatch** → 401 `miroir_invalid_auth`\n5. **Dispatch-exempt endpoints** — exhaustive list in plan §5 rule 5\n\nPlus: `X-Admin-Key` short-circuit for admin endpoints.\n\n## Why\n\nPlan §5: \"Three token types can appear on `Authorization: Bearer ` simultaneously — the `master_key`, the `admin_key`, and a search UI JWT. Miroir resolves them deterministically.\" Without a consistent dispatch chain, Phase 5 §13.21's JWT path conflicts with admin/master key on the same header. Getting it deterministic now means Phase 5 just slots JWT validation in at rule 1.\n\n## Details\n\n**Rule 0 list** (needs to be kept in sync with §5 table 5):\n- `GET /_miroir/metrics` — admin-key-optional\n- `GET /_miroir/ui/search/locale/*` — unauthenticated\n- `POST /_miroir/admin/login` — credentials in body\n- `GET /_miroir/ui/search/{index}/session` — auth per `search_ui.auth.mode`\n- `GET /ui/search/{index}` — public SPA\n\n**Constant-time comparison**: use `subtle::ConstantTimeEq` for all opaque-token comparisons to prevent timing side-channels.\n\n**Rate-limit hooks**: wire in `miroir:ratelimit:adminlogin:` and `miroir:ratelimit:searchui:` bucket counters from Phase 3 task store; Phase 2 may keep in-memory until Phase 6 multi-pod.\n\n## Acceptance\n\n- [ ] Every row in plan §5 rule 5 exempt list has a unit test (request does NOT match admin_key / master_key)\n- [ ] Opaque token on `/_miroir/*` matches only admin_key; never master_key\n- [ ] Opaque token on other paths matches only master_key; never admin_key\n- [ ] Missing Authorization on auth-gated endpoints → 401 `miroir_invalid_auth`\n- [ ] `X-Admin-Key` alone gates admin endpoints equivalently to Bearer admin_key\n- [ ] Constant-time compare: test with timing-injection harness shows no measurable delta between \"wrong length\" and \"wrong bytes\"","status":"closed","priority":0,"issue_type":"task","assignee":"charlie","created_at":"2026-04-18T21:28:30.212339590Z","created_by":"coding","updated_at":"2026-04-19T09:28:56.318500575Z","closed_at":"2026-04-19T09:28:56.318433182Z","close_reason":"P2.7 Auth bearer-token dispatch complete. All plan S5 rules 0-5 implemented in auth.rs (819 lines, 51 unit tests). All acceptance criteria met. Already committed in 625e414.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","phase-2"],"dependencies":[{"issue_id":"miroir-9dj.7","depends_on_id":"miroir-9dj","type":"parent-child","created_at":"2026-04-18T21:28:30.212339590Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-9dj.8","title":"P2.8 Middleware: structured logging + prometheus metrics + request IDs","description":"## What\n\nImplement `miroir-proxy::middleware`:\n- Request ID generation (UUIDv7 prefix short-hashed) attached as `X-Request-Id` on every response\n- Structured JSON log per plan §10 shape (timestamp, level, message, index, duration_ms, node_count, estimated_hits, degraded)\n- Prometheus histogram: `miroir_request_duration_seconds{method, path_template, status}`\n- Counter: `miroir_requests_total{method, path_template, status}`\n- Gauge: `miroir_requests_in_flight`\n- Scatter metrics: `miroir_scatter_fan_out_size`, `miroir_scatter_partial_responses_total`, `miroir_scatter_retries_total`\n- Node metrics: `miroir_node_healthy`, `miroir_node_request_duration_seconds`, `miroir_node_errors_total`\n\n## Why\n\nPhase 7 builds dashboards and alerts on these exact metric names. Defining them here (not at Phase 7) means every P2.X feature already emits the right signals without retrofit.\n\n**`path_template` (not `path`)** is critical: `/indexes/{uid}/search` is a template; substituting actual values produces high-cardinality labels that OOM Prometheus. Axum provides the matched route template via `MatchedPath` extractor.\n\n## Details\n\n**Log format** (plan §10 exact shape):\n```json\n{\n \"timestamp\": \"2026-05-01T12:00:00.000Z\",\n \"level\": \"info\",\n \"message\": \"search completed\",\n \"index\": \"products\",\n \"duration_ms\": 42,\n \"node_count\": 3,\n \"estimated_hits\": 15420,\n \"degraded\": false\n}\n```\n\nLogs go to stdout, one JSON object per line. Use `tracing-subscriber` with `fmt::layer().json()`.\n\n**In-flight gauge**: increment on request start, decrement via `Drop` guard so even panics decrement correctly.\n\n**Metrics server on `:9090`**: separate axum listener from the client API; no auth (bound to cluster network); `/metrics` returns prometheus exposition format.\n\n## Acceptance\n\n- [ ] `curl localhost:9090/metrics` returns all listed metrics with ≥ 1 sample after a single request\n- [ ] `jq` parses every log line without error\n- [ ] Request ID appears in response header and in the log entry for that request\n- [ ] High-cardinality defense: `path_template` never contains a UUID or arbitrary UID","status":"closed","priority":1,"issue_type":"task","assignee":"alpha","created_at":"2026-04-18T21:28:30.240006979Z","created_by":"coding","updated_at":"2026-04-19T09:26:03.275214168Z","closed_at":"2026-04-19T09:26:03.275102325Z","close_reason":"P2.8 Middleware: structured logging + prometheus metrics + request IDs\n\nImplementation already complete in commit fca081e. Verified all acceptance criteria:\n\n- curl localhost:9090/metrics returns all listed metrics with >= 1 sample after a single request\n- jq parses every log line without error \n- Request ID appears in response header (x-request-id) and in the log entry for that request\n- High-cardinality defense: path_template (e.g. /health, /indexes/{uid}/search) never contains a UUID or arbitrary UID - uses Axum MatchedPath extractor\n\nMetrics implemented:\n- miroir_request_duration_seconds{method, path_template, status}\n- miroir_requests_total{method, path_template, status}\n- miroir_requests_in_flight\n- miroir_scatter_fan_out_size\n- miroir_scatter_partial_responses_total\n- miroir_scatter_retries_total\n- miroir_node_healthy\n- miroir_node_request_duration_seconds\n- miroir_node_errors_total\n\nRequest ID generation uses UUIDv7 prefix short-hashed (16 hex chars). Structured JSON logging via tracing-subscriber with JSON formatter.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:5","phase-2"],"dependencies":[{"issue_id":"miroir-9dj.8","depends_on_id":"miroir-9dj","type":"parent-child","created_at":"2026-04-18T21:28:30.240006979Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-afh","title":"Phase 7 — Observability + Ops (§10)","description":"## Phase 7 Epic — Observability + Ops\n\nShips the metric set, log format, tracing hooks, alert rules, and Grafana dashboard specified in plan §10 + the resource-pressure additions from §14.9.\n\n## Why A Dedicated Phase\n\nObservability accretes badly: if you wire metrics per-feature, you end up with inconsistent naming, duplicate counters, and missing labels. Plan §10 names every metric up front so Phase 5 can depend on a stable registry. This phase makes sure the registry lines up with the plan and the Grafana dashboard reads real data.\n\n## Scope (plan §10 + §14.9)\n\n**Health endpoints**\n- `GET /health` — Meilisearch-compatible, used as liveness\n- `GET /_miroir/ready` — readiness; 503 until covering quorum reachable\n- `GET /_miroir/topology` — full cluster state (shape in plan §10)\n\n**Prometheus metrics** (all prefixed `miroir_`)\n- Requests: `miroir_request_duration_seconds{method,path_template,status}` histogram, `miroir_requests_total` counter, `miroir_requests_in_flight` gauge\n- Node health: `miroir_node_healthy{node_id}`, `miroir_node_request_duration_seconds{node_id,operation}`, `miroir_node_errors_total{node_id,error_type}`\n- Shards: `miroir_shard_coverage`, `miroir_degraded_shards_total`, `miroir_shard_distribution{node_id}`\n- Task registry: `miroir_task_processing_age_seconds`, `miroir_tasks_total{status}`, `miroir_task_registry_size`\n- Scatter-gather: `miroir_scatter_fan_out_size`, `miroir_scatter_partial_responses_total`, `miroir_scatter_retries_total`\n- Rebalancer: `miroir_rebalance_in_progress`, `miroir_rebalance_documents_migrated_total`, `miroir_rebalance_duration_seconds`\n- §13.11–21 family groups (all 11 listed in plan §10 \"Advanced capabilities metrics\")\n- §14.9 resource-pressure: `miroir_memory_pressure`, `miroir_cpu_throttled_seconds_total`, `miroir_request_queue_depth`, `miroir_background_queue_depth{job_type}`, `miroir_peer_pod_count`, `miroir_leader`, `miroir_owned_shards_count`\n\n**Ports**\n- Port 7700: `/_miroir/metrics` admin-key-gated\n- Port 9090: `/metrics` unauthenticated, pod-internal, ServiceMonitor target\n\n**Grafana dashboard** (`dashboards/miroir-overview.json`) — 8 panels per plan §10 + feature-flag-gated panels for §13.11–21 when flags are on\n\n**ServiceMonitor** (plan §10 YAML)\n\n**Alerting** (`PrometheusRule` per plan §10 + §14.9)\n- MiroirDegradedShards, MiroirNodeDown, MiroirHighSearchLatency, MiroirTaskStuck, MiroirRebalanceStuck\n- MiroirSettingsDivergence (paired with §13.5 reconciler)\n- MiroirAntientropyMismatch (paired with §13.8 at 3 consecutive passes)\n- MiroirMemoryPressure, MiroirRequestQueueBacklog, MiroirBackgroundJobBacklog, MiroirPeerDiscoveryGap, MiroirNoLeader\n\n**Tracing (optional)** — OpenTelemetry with configurable sample_rate; disabled by default; each search produces one parent span with a child per covering-set node\n\n**Log format** — structured JSON to stdout; schema per plan §10\n\n## Definition of Done\n\n- [ ] Every metric in plan §10 + §14.9 registered and scraping on port 9090\n- [ ] `/_miroir/metrics` on port 7700 returns identical data when admin-key-authenticated\n- [ ] Grafana dashboard JSON imports cleanly; all 8 core panels render from a live scrape\n- [ ] All 12 alerts live in the shipped PrometheusRule manifest\n- [ ] OTel trace contains one parent span per request and one child per node call\n- [ ] Log entries match the schema verbatim (parseable as JSON)\n- [ ] ServiceMonitor picks up the metrics service in a kind cluster test","status":"closed","priority":0,"issue_type":"epic","assignee":"delta","created_at":"2026-04-18T21:21:13.574251289Z","created_by":"coding","updated_at":"2026-04-26T15:38:32.071943896Z","closed_at":"2026-04-26T15:38:32.071819596Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:40","phase","phase-7"],"dependencies":[{"issue_id":"miroir-afh","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-18T21:23:08.669932412Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-afh.1","title":"P7.1 Core metrics families: requests, nodes, shards, tasks, scatter, rebalancer","description":"## What\n\nRegister the plan §10 core metric families on `:9090/metrics` AND `/_miroir/metrics` (admin-key gated mirror):\n\n**Requests** (histogram + counter + gauge):\n- `miroir_request_duration_seconds{method, path_template, status}`\n- `miroir_requests_total{method, path_template, status}`\n- `miroir_requests_in_flight`\n\n**Node health**:\n- `miroir_node_healthy{node_id}`\n- `miroir_node_request_duration_seconds{node_id, operation}`\n- `miroir_node_errors_total{node_id, error_type}`\n\n**Shards**:\n- `miroir_shard_coverage`\n- `miroir_degraded_shards_total`\n- `miroir_shard_distribution{node_id}`\n\n**Tasks**:\n- `miroir_task_processing_age_seconds`\n- `miroir_tasks_total{status}`\n- `miroir_task_registry_size`\n\n**Scatter-gather**:\n- `miroir_scatter_fan_out_size`\n- `miroir_scatter_partial_responses_total`\n- `miroir_scatter_retries_total`\n\n**Rebalancer**:\n- `miroir_rebalance_in_progress`\n- `miroir_rebalance_documents_migrated_total`\n- `miroir_rebalance_duration_seconds`\n\n## Why\n\nPlan §10 + Phase 9 dashboard + alerts all depend on these exact names. Naming is a contract — changing them post-v1.0 breaks every downstream dashboard + alert rule.\n\n## Details\n\n**Label cardinality defense**:\n- `path_template` MUST be the axum matched path (not the raw URL)\n- `node_id` is bounded (~dozens)\n- `status` is the HTTP status code (~10s)\n- `error_type` is enum-limited (not a raw error string)\n- `operation` is the backend call name ({search, documents_post, stats_get, ...})\n\n**Histogram buckets**: use prometheus default buckets for duration histograms unless the plan calls out specifics.\n\n**Port 9090 (unauth, pod-internal)** is the canonical scrape target; port 7700 `/_miroir/metrics` (admin-auth) returns identical data for ad-hoc inspection from outside.\n\n## Acceptance\n\n- [ ] `curl localhost:9090/metrics | grep '^miroir_'` lists every metric name above\n- [ ] `curl -H \"Authorization: Bearer $ADMIN_KEY\" localhost:7700/_miroir/metrics` returns the same data\n- [ ] `path_template` labels contain no UUIDs or dynamic segments\n- [ ] A request that hits 3 nodes produces a `miroir_scatter_fan_out_size` histogram sample of 3","status":"closed","priority":0,"issue_type":"task","assignee":"charlie","created_at":"2026-04-18T21:42:04.459011674Z","created_by":"coding","updated_at":"2026-04-19T15:37:09.024929760Z","closed_at":"2026-04-19T15:37:09.024863060Z","close_reason":"P7.1 complete: All 18 plan §10 core metric families registered on :9090/metrics and /_miroir/metrics (admin-key gated). Label cardinality bounded (path_template uses axum MatchedPath, no UUIDs). Search handler records scatter_fan_out_size. All 111 tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:61","phase-7"],"dependencies":[{"issue_id":"miroir-afh.1","depends_on_id":"miroir-afh","type":"parent-child","created_at":"2026-04-18T21:42:04.459011674Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-afh.2","title":"P7.2 §13.11-21 metric families wired behind feature flags","description":"## What\n\nRegister the §13.11–21 advanced-capabilities metric families (plan §10 \"Advanced capabilities metrics\") behind each feature's `enabled: true` flag:\n\n- Multi-search (§13.11): `miroir_multisearch_queries_per_batch`, `miroir_multisearch_batches_total`, `miroir_multisearch_partial_failures_total`, `miroir_tenant_session_pin_override_total{tenant}`\n- Vector (§13.12): `miroir_vector_search_over_fetched_total`, `miroir_vector_merge_strategy{strategy}`, `miroir_vector_embedder_drift_total`\n- CDC (§13.13): `miroir_cdc_events_published_total{sink,index}`, `miroir_cdc_lag_seconds{sink}`, `miroir_cdc_buffer_bytes{sink}`, `miroir_cdc_dropped_total{sink}`, `miroir_cdc_events_suppressed_total{origin}`\n- TTL (§13.14): `miroir_ttl_documents_expired_total{index}`, `miroir_ttl_sweep_duration_seconds{index}`, `miroir_ttl_pending_estimate{index}`\n- Tenant (§13.15): `miroir_tenant_queries_total{tenant,group}`, `miroir_tenant_pinned_groups{tenant}`, `miroir_tenant_fallback_total{reason}`\n- Shadow (§13.16): `miroir_shadow_diff_total{kind}`, `miroir_shadow_kendall_tau`, `miroir_shadow_latency_delta_seconds`, `miroir_shadow_errors_total{target,side}`\n- ILM (§13.17): `miroir_rollover_events_total{policy}`, `miroir_rollover_active_indexes{alias}`, `miroir_rollover_documents_expired_total{policy}`, `miroir_rollover_last_action_seconds{policy}`\n- Canary (§13.18): `miroir_canary_runs_total{canary,result}`, `miroir_canary_latency_ms{canary}`, `miroir_canary_assertion_failures_total{canary,assertion_type}`\n- Admin UI (§13.19): `miroir_admin_ui_sessions_total`, `miroir_admin_ui_action_total{action}`, `miroir_admin_ui_destructive_action_total{action}`\n- Explain (§13.20): `miroir_explain_requests_total`, `miroir_explain_warnings_total{warning_type}`, `miroir_explain_execute_total`\n- Search UI (§13.21): `miroir_search_ui_sessions_total`, `miroir_search_ui_queries_total{index}`, `miroir_search_ui_zero_hits_total{index}`, `miroir_search_ui_click_through_total{index}`, `miroir_search_ui_p95_ms{index}`\n\n## Why\n\nPlan §10 \"Grafana dashboard panels for these families will be added to `dashboards/miroir-overview.json` when the relevant feature flag is enabled; until then they are scrape-only.\" Gating by feature flag keeps the default scrape output compact for minimal deployments.\n\n## Details\n\n**Registration pattern**: each §13.x subsection's module owns its metrics `Lazy` / etc., registered into the global registry on first access (after `Config::validate` confirms the feature is enabled).\n\n**Label cardinality audit**: `{tenant}` and `{index}` are unbounded — document which metrics need dropping to cardinality caps (e.g., top 100 tenants reported individually, rest bucketed as \"other\"). Decide per metric during implementation; note decisions in feature-specific beads.\n\n## Acceptance\n\n- [ ] With all §13 flags off, `curl :9090/metrics | grep '^miroir_' | wc -l` is close to the Phase 7 P7.1 count (only core families emit)\n- [ ] With all §13 flags on, every family name above appears in the scrape\n- [ ] Label cardinality: any `{tenant}` or `{index}` metric bounded per its per-feature cap (not unlimited)","status":"closed","priority":1,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:42:04.479172125Z","created_by":"coding","updated_at":"2026-04-19T16:49:47.330734766Z","closed_at":"2026-04-19T16:49:47.330528991Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:6","phase-7"],"dependencies":[{"issue_id":"miroir-afh.2","depends_on_id":"miroir-afh","type":"parent-child","created_at":"2026-04-18T21:42:04.479172125Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-afh.2","depends_on_id":"miroir-afh.1","type":"blocks","created_at":"2026-04-18T21:42:08.230920336Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-afh.3","title":"P7.3 Grafana dashboard: dashboards/miroir-overview.json","description":"## What\n\nBuild the plan §10 Grafana dashboard at `dashboards/miroir-overview.json` with 8 panels:\n1. Cluster health — degraded shards, node healthy table\n2. Request rate — by path template\n3. p50/p95/p99 latency\n4. Node latency comparison — per-node histogram quantiles\n5. Search overhead — Miroir vs. single-node Meilisearch ratio\n6. Task lag — stuck task age\n7. Shard distribution — imbalance detection\n8. Rebalance activity\n\nPlus conditional feature-flag-gated rows for:\n- §13.1 resharding in progress + phase gauge\n- §13.5 settings broadcast phase + drift repairs\n- §13.8 anti-entropy shards scanned, mismatches found, docs repaired\n- §13.13 CDC lag, buffer bytes, events by sink\n- §13.18 canary pass/fail heatmap\n- §13.21 search UI sessions + p95\n\n## Why\n\nPlan §10 + §12 list the dashboard as a delivered artifact. A sample dashboard shipped in the repo means operators don't reinvent it for each install — they import and customize.\n\n## Details\n\n**Prometheus data source**: parametrized via `$datasource` variable so operators point at their cluster's Prometheus.\n\n**Row visibility**: use Grafana's \"template variable\" controlling row visibility — set automatic via `enabled_feature` label on metrics (or via a separate `miroir_feature_enabled{feature}` gauge) so rows auto-show when scraped.\n\n**Timezone**: default `browser`; 1-minute refresh; 1-hour default time range.\n\n**Import flow**: `helm install` optional `dashboards.enabled: true` creates a ConfigMap with the JSON labeled `grafana_dashboard=1` so Grafana's sidecar auto-imports.\n\n## Acceptance\n\n- [ ] `dashboards/miroir-overview.json` imports into a stock Grafana v10.x without errors\n- [ ] Every panel renders data against a live Miroir scrape in Phase 9 integration cluster\n- [ ] Feature-gated rows hide when their metrics are absent; show when present","status":"closed","priority":0,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:42:04.502212851Z","created_by":"coding","updated_at":"2026-04-19T17:18:54.202219947Z","closed_at":"2026-04-19T17:18:54.201899062Z","close_reason":"Added §13.1 Resharding feature-gated row (in-progress stat, phase gauge, backfill rate). Fixed y-coordinate overlap between Anti-Entropy and Settings Broadcast rows. Synced chart copy. Dashboard now has 8 core panels + 7 feature-gated collapsed rows (§13.1, §13.5, §13.8, §13.11, §13.13, §13.18, §13.21).","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","phase-7"],"dependencies":[{"issue_id":"miroir-afh.3","depends_on_id":"miroir-afh","type":"parent-child","created_at":"2026-04-18T21:42:04.502212851Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-afh.3","depends_on_id":"miroir-afh.1","type":"blocks","created_at":"2026-04-18T21:42:08.247243544Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-afh.3","depends_on_id":"miroir-afh.2","type":"blocks","created_at":"2026-04-18T21:42:08.270326589Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-afh.4","title":"P7.4 ServiceMonitor + PrometheusRule (alerts) manifests","description":"## What\n\nShip the plan §10 + §14.9 alerting rules via `PrometheusRule` and the metric-scraping via `ServiceMonitor`.\n\n## ServiceMonitor (plan §10)\n\n```yaml\napiVersion: monitoring.coreos.com/v1\nkind: ServiceMonitor\nmetadata:\n name: miroir\nspec:\n selector: { matchLabels: { app.kubernetes.io/name: miroir, app.kubernetes.io/component: metrics } }\n endpoints:\n - port: metrics\n interval: 30s\n path: /metrics\n```\n\n## PrometheusRule (plan §10 + §14.9)\n\nAlerts (all 12 from plan):\n\n### Availability (plan §10)\n1. `MiroirDegradedShards` — `miroir_degraded_shards_total > 0` for 2m\n2. `MiroirNodeDown` — `miroir_node_healthy == 0` for 5m\n3. `MiroirHighSearchLatency` — p95 > 2s for 5m\n4. `MiroirTaskStuck` — `miroir_task_processing_age_seconds > 3600` for 10m\n5. `MiroirRebalanceStuck` — `miroir_rebalance_in_progress == 1` for 2h\n6. `MiroirSettingsDivergence` — paired with §13.5 auto-repair (plan §10 description)\n7. `MiroirAntientropyMismatch` — paired with §13.8 at 3 consecutive passes (~18h default schedule)\n\n### Resource pressure (plan §14.9)\n8. `MiroirMemoryPressure` — `miroir_memory_pressure >= 2` for 5m\n9. `MiroirRequestQueueBacklog` — `miroir_request_queue_depth > 500` for 2m\n10. `MiroirBackgroundJobBacklog` — `miroir_background_queue_depth > 100` for 10m\n11. `MiroirPeerDiscoveryGap` — peer mismatch for 2m\n12. `MiroirNoLeader` — `sum(miroir_leader) == 0` for 1m\n\n## Why\n\nAlert rules are part of the shipped product, not something operators have to write. Plan §10 is explicit: the rules fire \"only when the self-healing paths described [in §13.5 / §13.8] failed to close the gap on their own\" — so noise is minimized and every page is actionable.\n\n## Details\n\n**Helm flag**: `miroir.serviceMonitor.enabled: false` default (only render when operator opts in, requires prometheus-operator in cluster). Same for `miroir.prometheusRule.enabled: false`.\n\n**Alert routing**: operators wire to their own Alertmanager — Miroir doesn't ship routing config.\n\n## Acceptance\n\n- [ ] `helm template` with `serviceMonitor.enabled: true` renders a valid ServiceMonitor manifest\n- [ ] All 12 alerts present in the rendered PrometheusRule\n- [ ] Each alert tripped at least once in Phase 9 chaos tests (where applicable)","status":"closed","priority":0,"issue_type":"task","assignee":"charlie","created_at":"2026-04-18T21:42:04.550227072Z","created_by":"coding","updated_at":"2026-04-19T15:43:11.826908423Z","closed_at":"2026-04-19T15:43:11.826791560Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-7"],"dependencies":[{"issue_id":"miroir-afh.4","depends_on_id":"miroir-afh","type":"parent-child","created_at":"2026-04-18T21:42:04.550227072Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-afh.4","depends_on_id":"miroir-afh.1","type":"blocks","created_at":"2026-04-18T21:42:08.287293376Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-afh.5","title":"P7.5 Structured JSON logging + request IDs + trace correlation","description":"## What\n\nImplement plan §10 structured JSON log format:\n```json\n{\n \"timestamp\": \"2026-05-01T12:00:00.000Z\",\n \"level\": \"info\",\n \"message\": \"search completed\",\n \"index\": \"products\",\n \"duration_ms\": 42,\n \"node_count\": 3,\n \"estimated_hits\": 15420,\n \"degraded\": false\n}\n```\n\nEvery log entry includes `request_id` (UUIDv7-prefix short-hash, same value as the `X-Request-Id` response header from P2.8) so a log search can trace a single request across pods.\n\n## Why\n\nStructured logs are the only log format that scales beyond \"grep through ASCII.\" JSON-per-line is parseable by every log aggregator (Loki, ElasticSearch, Splunk, CloudWatch).\n\n## Details\n\n**Tracing subscriber stack**:\n```rust\nuse tracing_subscriber::prelude::*;\ntracing_subscriber::registry()\n .with(tracing_subscriber::fmt::layer().json())\n .with(tracing_subscriber::EnvFilter::from_default_env())\n .init();\n```\n\n**Fields on every log line**: `timestamp`, `level`, `target` (module path), `request_id` (from axum middleware), `pod_id` (env `POD_NAME`), `message`. Plus free-form context per log call (`index`, `shard`, `duration_ms`, ...).\n\n**Log levels**:\n- `ERROR`: orchestrator-side internal failures\n- `WARN`: degraded responses, fallbacks, soft failures\n- `INFO`: one line per request with summary fields\n- `DEBUG`: per-node calls, per-sub-query in multi-search\n- `TRACE`: fan-out buffer contents, scatter plan internals\n\n**No PII**: never log document content, query strings, or API keys. Hashes of keys are fine (for correlation across requests).\n\n## Acceptance\n\n- [ ] `jq` parses every log line\n- [ ] Grepping `request_id=abc123` across all pods' logs returns one-line-per-pod-that-handled-part-of-that-request\n- [ ] No API key, document field, or user query appears in any log entry\n- [ ] Log volume: < 1 entry per client request at INFO level; more at DEBUG only when env filter allows","status":"closed","priority":1,"issue_type":"task","assignee":"tango","created_at":"2026-04-18T21:42:04.602737281Z","created_by":"coding","updated_at":"2026-04-24T23:10:39.844800526Z","closed_at":"2026-04-24T23:10:39.844649626Z","close_reason":"P7.5 complete: all 16 structured logging tests pass. JSON-per-line subscriber with flatten_event(true), X-Request-Id middleware, request_id in every log event, no PII, 2 INFO entries per search request.","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-7"],"dependencies":[{"issue_id":"miroir-afh.5","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.492097079Z","created_by":"coding","metadata":"{}","thread_id":""}],"comments":[{"id":1,"issue_id":"miroir-afh.5","author":"coding","text":"Summary: All 16 P7.5 tests pass. Implementation complete.\n\nRetrospective:\n- What worked: flatten_event(true) on the JSON layer flattens span fields to top-level keys, making every log line jq-parseable. with_current_span(true) propagates request_id and pod_id to all child events automatically.\n- What did not: Initial attempt nested span fields under 'span', breaking the §10 schema top-level requirement.\n- Surprise: Implementation was already shipped in prior commits (P7.5.b series). This session verified all 16 acceptance tests pass and closed the bead.\n- Reusable pattern: Global pod_id span at startup + per-request request_id span in telemetry middleware — both flow through automatically without handler changes.","created_at":"2026-04-24T23:10:36Z"}]} -{"id":"miroir-afh.5.1","title":"P7.5.a Request ID middleware + X-Request-Id response header","description":"## Scope\n- axum middleware that generates a UUIDv7 per inbound request\n- Short-hash prefix (first 8 chars) exposed as \\`X-Request-Id\\` response header\n- Attached to the axum Request extensions so handlers can read the current request_id\n- No log format work here (that's .b); no tracing integration (that's .c)\n\n## Files\n- \\`crates/miroir-proxy/src/middleware.rs\\` — add \\`request_id_layer()\\`\n- Wire into server builder\n\n## Acceptance\n- [ ] Every response includes \\`X-Request-Id: <8-char hex>\\`\n- [ ] `Request.extensions().get::()` works from any handler\n- [ ] Unit test: two consecutive requests produce different IDs","status":"closed","priority":1,"issue_type":"task","assignee":"glm47-b","created_at":"2026-04-21T11:07:25.744582499Z","created_by":"coding","updated_at":"2026-04-21T12:01:48.308959874Z","closed_at":"2026-04-21T12:01:48.308860703Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:23","phase-7"],"dependencies":[{"issue_id":"miroir-afh.5.1","depends_on_id":"miroir-afh.5","type":"parent-child","created_at":"2026-04-21T11:07:25.744582499Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-afh.5.2","title":"P7.5.b Structured JSON log format matching plan §10 schema","description":"## Scope\nImplement plan §10 log schema: \\`{timestamp, level, message, index, duration_ms, node_count, estimated_hits, degraded}\\` (one JSON object per line to stdout).\n\n## Files\n- \\`crates/miroir-proxy/src/main.rs\\` tracing init — use \\`tracing_subscriber::fmt::layer().json()\\`\n- Structured fields attached at log call sites\n\n## Acceptance\n- [ ] Every log line parses as JSON (jq -c .)\n- [ ] Request handlers emit \\`message=\\\"search completed\\\"\\` with all §10 fields populated\n- [ ] No PII (no query strings, no API keys, no doc bodies) — only hashes of keys","status":"closed","priority":1,"issue_type":"task","assignee":"golf","created_at":"2026-04-21T11:07:25.765256439Z","created_by":"coding","updated_at":"2026-04-24T01:43:47.759670284Z","closed_at":"2026-04-24T01:43:47.759563923Z","close_reason":"P7.5.b complete. Plan §10 structured JSON log format implemented: (1) JSON-per-line via tracing_subscriber .json().flatten_event(true), (2) search handler emits all §10 fields (index, duration_ms, node_count, estimated_hits, degraded) with message='search completed', (3) no PII — SearchRequestBody redacts q/filter, middleware uses path_template, keys never logged. All 16 P7.5 tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:55","phase-7"],"dependencies":[{"issue_id":"miroir-afh.5.2","depends_on_id":"miroir-afh.5","type":"parent-child","created_at":"2026-04-21T11:07:25.765256439Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-afh.5.3","title":"P7.5.c Trace correlation: request_id wired into logs + spans","description":"## Scope\nConnect P7.5.a request IDs to P7.5.b structured logs. Every log line inside a request must carry \\`request_id=\\`.\n\n## Depends on\n- miroir-afh.5.a (request_id middleware)\n- miroir-afh.5.b (JSON log format)\n\n## Approach\nUse \\`tracing::Span\\` with \\`request_id\\` recorded on span enter; \\`tracing_subscriber::fmt().with_current_span(true)\\` so every event inside the span carries the field.\n\n## Acceptance\n- [ ] Grepping request_id=abc123 across all pods returns every log line from that request\n- [ ] Non-request logs (startup, background tasks) don't have request_id field (absence is intentional)","status":"closed","priority":1,"issue_type":"task","assignee":"hotel","created_at":"2026-04-21T11:07:25.787563599Z","created_by":"coding","updated_at":"2026-04-26T14:40:46.164472292Z","closed_at":"2026-04-26T14:40:46.164410889Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:27","phase-7"],"dependencies":[{"issue_id":"miroir-afh.5.3","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.191185884Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-afh.5.3","depends_on_id":"miroir-afh.5.1","type":"blocks","created_at":"2026-04-21T11:07:25.826828808Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-afh.5.3","depends_on_id":"miroir-afh.5.2","type":"blocks","created_at":"2026-04-21T11:07:25.854477618Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-afh.6","title":"P7.6 OpenTelemetry tracing (optional, off by default)","description":"## What\n\nImplement plan §10 tracing (disabled by default):\n```yaml\nmiroir:\n tracing:\n enabled: false\n endpoint: \"http://tempo.monitoring.svc:4317\"\n service_name: miroir\n sample_rate: 0.1\n```\n\nWhen enabled, every search produces a trace with parallel spans for each node in the covering set.\n\n## Why\n\nPlan §10: \"makes latency outliers immediately visible.\" A scatter with one slow node shows up as one span sticking out from the parallel pack — operators can immediately point at the node.\n\n## Details\n\n**OTel SDK**: `opentelemetry` + `opentelemetry-otlp` + `tracing-opentelemetry`. Hook into the existing `tracing` subscriber chain.\n\n**Span hierarchy**:\n- Parent span: inbound request (`POST /indexes/products/search`)\n- Child span: scatter plan construction\n- Parallel child spans: one per node in covering set (`call meili-1`, `call meili-2`, ...)\n- Parallel child spans within the scatter: any hedges fired (§13.2)\n- Merge span: after gather completes\n\n**Sampling**: head-based `sample_rate` in config. Tail-based (e.g., always sample slow traces) is a future enhancement; v1 ships head-based only.\n\n**Resource attributes**: `service.name`, `service.version`, `host.name` (pod name).\n\n**Disabled default**: no overhead when off (the subscriber chain skips the OTel layer entirely).\n\n## Acceptance\n\n- [ ] `tracing.enabled: false` → zero OTel library calls in a CPU profile\n- [ ] `tracing.enabled: true` + Tempo running → traces appear within seconds\n- [ ] A slow-node induced in Phase 9 chaos produces a visible outlier span in Tempo\n- [ ] Sample rate 0.1 results in ~10% of requests producing traces","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:42:04.629100946Z","created_by":"coding","updated_at":"2026-04-19T14:24:45.970412367Z","closed_at":"2026-04-19T14:24:45.970348004Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:8","phase-7"],"dependencies":[{"issue_id":"miroir-afh.6","depends_on_id":"miroir-afh","type":"parent-child","created_at":"2026-04-18T21:42:04.629100946Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-b64","title":"Genesis: Miroir Implementation","description":"## Genesis Bead\n**Tied to plan:** `/home/coding/miroir/docs/plan/plan.md`\n\n## Project Overview\n\n**Miroir** — _Multi-node Index Replication Orchestrator, Integrated Rebalancing_ — is a RAID-like sharding and high-availability layer for **Meilisearch Community Edition (MIT)**. It stripes a large index across a fleet of Meilisearch nodes, fans out search queries across all shards, merges ranked results, and rebalances shard assignments when nodes are added or removed — all without Meilisearch Enterprise.\n\n## Why This Exists\n\nMeilisearch CE loads its entire index into memory-mapped LMDB files. A large index that exceeds a single server's available RAM cannot run on that server. The Enterprise Edition's native sharding and replication are **BUSL-1.1 gated** — production use requires a commercial license. Miroir solves this using only the Meilisearch **public REST API**, with no node-side patches or forks. Every Meilisearch node continues to run unmodified CE.\n\n## Design Principles (from plan §1)\n\n1. **Invisible federation** — clients talk to one endpoint using the standard Meilisearch API\n2. **No Enterprise dependency** — pure CE (MIT) everywhere\n3. **Rendezvous hashing (HRW)** — matches what Meilisearch Enterprise itself uses internally\n4. **RF-configurable redundancy** — RF=1 capacity, RF=2 one-node-loss, RF=3 two-node-loss\n5. **Graceful degradation** — partial results with `X-Miroir-Degraded` beats whole-request failure\n6. **Static binaries, scratch images** — musl + scratch Docker, trivial deploy, tiny attack surface\n7. **GitOps first** — all config in `jedarden/declarative-config`, ArgoCD drives cluster changes\n8. **Fixed per-pod resource envelope (2 vCPU / 3.75 GB)** — scale out, not up\n\n## Architecture (high-level)\n\n- **Shards (S)** — logical hash-space granularity, **fixed at index creation**, `S = max_nodes_per_group_ever × 8`\n- **Replica Groups (RG)** — independent query pools, each holds a full copy of all shards; scales **read throughput**\n- **Replication Factor (RF)** — intra-group copies per shard; scales **HA within a group**\n- **Writes** fan out to `RG × RF` nodes (one per-group quorum, cluster-wide success when ≥1 group met its quorum)\n- **Reads** target exactly one group per query (round-robin); fan out to that group's covering set only\n- **Rendezvous hashing is scoped to each group** — prevents cross-group coverage gaps\n\n## Phase Plan\n\n- [ ] **Phase 0 — Foundation** — Cargo workspace, crate layout, config schema, dependencies\n- [ ] **Phase 1 — Core Routing** (plan §2, §4) — rendezvous hash, topology, write targets, covering set\n- [ ] **Phase 2 — Proxy + API Surface** (plan §3, §5) — HTTP server, documents/search/indexes/settings/tasks/health, result merger, quorum, error mapping\n- [ ] **Phase 3 — Task Registry + Persistence** (plan §4 task store) — SQLite schema (14 tables), Redis mirror for HA\n- [ ] **Phase 4 — Topology Operations** (plan §2 topology changes, §4 rebalancer) — add/remove node, add/remove group, drain, dual-write, shard-filter migration\n- [ ] **Phase 5 — Advanced Capabilities** (plan §13, subsections .1–.21) — reshard, hedging, EWMA, query planner, two-phase settings, session pinning, aliases, anti-entropy, streaming dump import, idempotency+coalescing, multi-search, vector, CDC, TTL, tenant affinity, shadow tee, ILM, canaries, Admin UI, Explain, Search UI\n- [ ] **Phase 6 — Horizontal Scaling + HPA** (plan §14) — pod envelope, request-path statelessness, Mode A/B/C background coordination, peer discovery, HPA spec\n- [ ] **Phase 7 — Observability + Ops** (plan §10) — metrics, tracing, logs, alerts, Grafana dashboard, ServiceMonitor\n- [ ] **Phase 8 — Deployment + CI** (plan §6, §7) — Dockerfile (scratch+musl), Helm chart, ArgoCD Application, Argo Workflow template\n- [ ] **Phase 9 — Testing** (plan §8) — unit, integration (docker-compose), compatibility, chaos, performance (criterion), SDK smoke tests\n- [ ] **Phase 10 — Security + Secrets** (plan §9) — sealed secrets, ESO/OpenBao integration, key rotation (admin-scoped, JWT, scoped-key), CSRF posture\n- [ ] **Phase 11 — Onboarding + Docs + Delivered Artifacts** (plan §11, §12) — README, CHANGELOG, migration docs, miroir-ctl help, runbooks, release checklist\n- [ ] **Phase 12 — Open Problems Tracking** (plan §15) — score normalization at scale validation, arm64 support, Raft-based HA task state exploration\n\n## How to use this bead\n\n- Each phase has its own epic bead that blocks this genesis bead\n- Every phase epic decomposes into concrete task beads; most tasks have subtasks\n- Dependencies are wired so ready-work can be discovered with `br ready`\n- Close phase epics as they complete; update the checklist above by editing this bead's body\n- Close this genesis bead only when all phases are complete AND `br ready` returns empty\n\n## Cross-cutting references\n\n- Infrastructure: Hetzner EX44 + Tailscale + iad-ci Argo Workflows (see `/home/coding/CLAUDE.md`)\n- Container registry: `ghcr.io/jedarden/miroir`\n- Helm chart OCI: `ghcr.io/jedarden/charts/miroir`\n- GitHub Pages: `https://jedarden.github.io/miroir`\n- Declarative config repo: `jedarden/declarative-config → k8s/iad-ci/argo-workflows/miroir-ci.yaml`\n- Argo UI: `https://argo-ci.ardenone.com` (VPN+SSO)\n- ArgoCD read-only API: `https://argocd-ro-ardenone-manager-ts.ardenone.com:8444`\n\n## Resources\n\n- Plan doc: `/home/coding/miroir/docs/plan/plan.md` (3739 lines, authoritative)\n- Research: `/home/coding/miroir/docs/research/{ha-approaches,consistent-hashing,distributed-search-patterns}.md`\n- Notes: `/home/coding/miroir/docs/notes/api-compatibility.md`","status":"open","priority":0,"issue_type":"genesis","created_at":"2026-04-18T21:16:57.035422879Z","created_by":"coding","updated_at":"2026-04-18T21:23:03.980674624Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["epic","genesis"],"dependencies":[{"issue_id":"miroir-b64","depends_on_id":"miroir-46p","type":"blocks","created_at":"2026-04-18T21:23:03.914397943Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-b64","depends_on_id":"miroir-89x","type":"blocks","created_at":"2026-04-18T21:23:03.880994818Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-b64","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-18T21:23:03.707537245Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-b64","depends_on_id":"miroir-afh","type":"blocks","created_at":"2026-04-18T21:23:03.828449381Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-b64","depends_on_id":"miroir-cdo","type":"blocks","created_at":"2026-04-18T21:23:03.693122638Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-b64","depends_on_id":"miroir-m9q","type":"blocks","created_at":"2026-04-18T21:23:03.812940820Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-b64","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-18T21:23:03.751578908Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-b64","depends_on_id":"miroir-qjt","type":"blocks","created_at":"2026-04-18T21:23:03.851889265Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-b64","depends_on_id":"miroir-qon","type":"blocks","created_at":"2026-04-18T21:23:03.678271938Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-b64","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-18T21:23:03.725188496Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-b64","depends_on_id":"miroir-uhj","type":"blocks","created_at":"2026-04-18T21:23:03.780275977Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-b64","depends_on_id":"miroir-uyx","type":"blocks","created_at":"2026-04-18T21:23:03.949940719Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-b64","depends_on_id":"miroir-zc2","type":"blocks","created_at":"2026-04-18T21:23:03.980624158Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-cdo","title":"Phase 1 — Core Routing (rendezvous hash, topology, covering set)","description":"## Phase 1 Epic — Core Routing\n\nImplements the deterministic, coordination-free routing primitives that everything else depends on. After this phase, given a fixed topology + config, any Miroir pod can independently compute identical write targets and covering sets — no coordination required.\n\n## Why This Matters\n\nPlan §1 principle 3: rendezvous hashing (HRW) is the same algorithm Meilisearch Enterprise uses internally with twox-hash. Getting this right has **three** properties we rely on downstream:\n\n1. **Determinism** — all pods agree on assignments without any gossip protocol\n2. **Minimal reshuffling** — adding a node to a group moves only ~1/(Ng+1) of that group's docs (plan §2 \"Properties\" bullets)\n3. **Group isolation** — hashing scoped to intra-group node lists prevents both replicas of a shard from landing in the same group (plan §2 \"Why group-scoped assignment matters\")\n\nThese properties are the foundation for the §2 write path, §2 read path, §4 rebalancer, §13.3 adaptive selection, §13.4 query planner, §13.8 anti-entropy, and §14.5 Mode A shard-partitioned ownership. A subtle bug here — e.g., seeding the hash differently, using a non-stable node-id encoding — corrupts every later layer silently.\n\n## Scope (plan §2 Architecture + §4 router.rs)\n\n- `router.rs` — `score(shard, node)`, `assign_shard_in_group`, `write_targets`, `query_group`, `covering_set`, `shard_for_key`\n- `topology.rs` — `Topology` struct (nodes grouped by `replica_group`), node health state machine (healthy / degraded / draining / failed / joining / active / removed)\n- `scatter.rs` — fan-out orchestration primitives (stubbed execution; wired in Phase 2)\n- `merger.rs` — result merge primitives (global sort by `_rankingScore`, offset/limit, facet aggregation, estimatedTotalHits summation, `_miroir_shard` + `_rankingScore` stripping) — pure-function friendly for unit testing\n- Unit tests per §8 \"Router correctness\" + \"Result merger\" bullets\n\n## Definition of Done\n\n- [ ] Rendezvous assignment is deterministic given fixed node list (verified by test)\n- [ ] Adding a 4th node in a 3-node group moves at most ~2 × (1/4) of shards (verified by test, plan §8)\n- [ ] 64 shards / 3 nodes / RF=1 → each node holds 18–26 shards (verified by test)\n- [ ] Top-RF placement changes minimally on add / remove (verified by test)\n- [ ] `write_targets` returns exactly `RG × RF` nodes, one from each group\n- [ ] `query_group(seq, RG)` distributes evenly (verified by test)\n- [ ] `covering_set` within a group returns exactly one node per shard (with intra-group replica rotation)\n- [ ] `merger` passes the merge/facet/limit tests in plan §8\n- [ ] `miroir-core` ≥ 90% line coverage via cargo-tarpaulin (per §8 coverage policy)","status":"closed","priority":0,"issue_type":"epic","assignee":"alpha","created_at":"2026-04-18T21:18:33.134146061Z","created_by":"coding","updated_at":"2026-04-19T08:54:25.522736530Z","closed_at":"2026-04-19T08:54:25.522618659Z","close_reason":"Phase 1 Core Routing complete. All DoD items verified: 199 unit tests + 12 property tests + 10 integration tests passing, clippy clean. Rendezvous assignment deterministic, reshuffle bounds verified, uniformity verified, write_targets/query_group/covering_set/merger all tested.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","phase","phase-1"],"dependencies":[{"issue_id":"miroir-cdo","depends_on_id":"miroir-qon","type":"blocks","created_at":"2026-04-18T21:23:08.556785813Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-cdo.1","title":"P1.1 Rendezvous hash primitives (score, assign_shard_in_group)","description":"## What\n\nImplement `miroir_core::router`:\n```rust\npub fn score(shard_id: u32, node_id: &str) -> u64\npub fn assign_shard_in_group(shard_id: u32, group_nodes: &[NodeId], rf: usize) -> Vec\npub fn shard_for_key(primary_key: &str, shard_count: u32) -> u32\n```\n\n## Why\n\nThese three are the atoms everything else builds on. `score` uses `XxHash64::with_seed(0)` with the canonical concatenation order `(shard_id, node_id)` (plan §4 code sample). Any deviation (different seed, different ordering, endianness) forks routing across any two Miroir instances and silently corrupts writes.\n\n## Design Notes (plan §2 / §4)\n\n- **Hash function is `twox-hash` (XxHash family)** — the same one Meilisearch Enterprise uses; the choice is non-negotiable (plan §2).\n- **Node-id encoding stability** — the string passed to `node_id.hash(&mut h)` must be byte-stable. Use the bare `id: \"meili-0\"` string from config, not a reformatted address.\n- **`assign_shard_in_group` is group-scoped on purpose** — per plan §2 \"Why group-scoped assignment matters\": scoping to the group prevents both replicas of a shard from landing in the same group. A global rendezvous would have no such guarantee.\n- **Sort by score descending, break ties lexicographically on node_id** so two nodes with identical hash scores (extremely rare but possible) deterministically resolve.\n\n## Acceptance Tests (plan §8 \"Router correctness\")\n\n- [ ] Determinism: same `(shard_id, nodes)` → identical `Vec` across 1000 randomized runs\n- [ ] Reshuffle bound on add: 64 shards, 3→4 nodes in a group → at most `2 × (1/4) × 64` shard-node edges differ\n- [ ] Reshuffle bound on remove: 64 shards, 4→3 nodes → `~RF × S / Ng` edges differ\n- [ ] Uniformity: 64 shards, 3 nodes, RF=1 → each node holds 18–26 shards (chi-square not rejected at p=0.95)\n- [ ] RF=2 placement: top-2 nodes change minimally when a node is added or removed\n- [ ] `shard_for_key(pk, S)` is `(XxHash64::with_seed(0).hash(pk) % S)` — verified against a known fixture vector","status":"closed","priority":0,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:26:11.754243556Z","created_by":"coding","updated_at":"2026-04-19T03:47:59.776479292Z","closed_at":"2026-04-19T03:47:59.776362081Z","close_reason":"P1.1 Complete: Fixed shard_for_key fixture test values\n\nThe three rendezvous hash primitives were already implemented:\n- score(shard_id, node_id) using XxHash64::with_seed(0) with canonical order (shard_id, node_id)\n- assign_shard_in_group with lexicographic tie-breaking\n- shard_for_key using direct hash modulo\n\nFixed incorrect fixture values in test:\n- order:xyz → 10 (was 25)\n- alpha → 104 (was 121) \n- beta → 91 (was 93)\n\nAll 8 acceptance tests pass:\n- Determinism ✓\n- Reshuffle bound on add ✓\n- Reshuffle bound on remove ✓\n- Uniformity ✓\n- RF=2 placement stability ✓\n- shard_for_key fixture ✓","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","phase-1"],"dependencies":[{"issue_id":"miroir-cdo.1","depends_on_id":"miroir-cdo","type":"parent-child","created_at":"2026-04-18T21:26:11.754243556Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-cdo.2","title":"P1.2 Topology type + node state machine","description":"## What\n\nImplement `miroir_core::topology`:\n```rust\npub struct Topology {\n pub shards: u32,\n pub replica_groups: u32,\n pub rf: usize,\n pub nodes: Vec,\n}\npub struct Node {\n pub id: NodeId,\n pub address: String,\n pub replica_group: u32,\n pub status: NodeStatus,\n}\npub enum NodeStatus { Healthy, Degraded, Draining, Failed, Joining, Active, Removed }\n```\n\nHelpers: `Topology::groups() -> impl Iterator`, `Topology::group(g: u32) -> &Group`, `group.nodes() -> &[Node]`, `group.healthy_nodes() -> Vec<&Node>`.\n\n## Why\n\nThe `Topology` type is what `router` operates on. State transitions correspond to plan §2 topology-change verbs: a node is `Joining` → `Active` after a group-add migration; `Draining` → `Removed` after a node-remove migration; `Failed` is for unplanned loss.\n\nThe state field matters for **routing-eligibility**: writes skip `Draining` for *affected* shards (plan §2 \"Removing a node\" step 1), but still deliver to it for shards it still owns. A bug where a `Draining` node stops receiving any writes prematurely would create durability gaps during rebalance.\n\n## State Transition Rules\n\n| From | To | Triggered by |\n|------|-----|-------------|\n| (new) | Joining | `POST /_miroir/nodes` (plan §4 admin API) |\n| Joining | Active | Migration complete (Phase 4) |\n| Active | Draining | `POST /_miroir/nodes/{id}/drain` |\n| Draining | Removed | Migration complete (Phase 4) |\n| Active/Draining | Failed | Health check detects (Phase 7) |\n| Failed | Active | Health check recovery + optional replication catch-up |\n| Active/Failed | Degraded | Partial health (timeouts, not full disconnect) |\n| Degraded | Active | Health restored |\n\n## Acceptance\n\n- [ ] Topology deserializes from plan §4 YAML example (RG=2, 6 nodes, RF=1) into the expected shape\n- [ ] `groups()` iterator returns `RG` groups in ascending order; each group holds exactly its configured nodes\n- [ ] State-machine unit tests cover every legal transition and reject illegal ones (e.g., Joining → Draining)\n- [ ] `Node::is_write_eligible_for(shard_id, status)` correctness table has a test per row","status":"closed","priority":0,"issue_type":"task","assignee":"delta","created_at":"2026-04-18T21:26:11.777790379Z","created_by":"coding","updated_at":"2026-04-19T04:06:04.329548111Z","closed_at":"2026-04-19T04:06:04.329417610Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","phase-1"],"dependencies":[{"issue_id":"miroir-cdo.2","depends_on_id":"miroir-cdo","type":"parent-child","created_at":"2026-04-18T21:26:11.777790379Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-cdo.3","title":"P1.3 write_targets and covering_set","description":"## What\n\nImplement the two flat API calls used by the HTTP layer:\n```rust\npub fn write_targets(shard_id: u32, topology: &Topology) -> Vec\npub fn query_group(query_seq: u64, replica_groups: u32) -> u32\npub fn covering_set(shard_count: u32, group: &Group, rf: usize, query_seq: u64) -> Vec\n```\n\n## Why / Semantics (plan §2)\n\n**`write_targets`** — flat union of `assign_shard_in_group(shard, g)` across all `RG` groups. Returns `RG × RF` nodes total (may include duplicates across groups if a node_id coincidentally has the highest score in multiple groups — use a dedup pass in the HTTP layer when grouping docs per-request rather than dedup here, so the routing layer's behavior is pure).\n\n**`query_group`** — round-robin per the plan's note: \"`query_sequence_number` is a per-pod counter, not a cluster-wide one.\" Under HPA, cluster-wide balance relies on the K8s Service's round-robin / random kube-proxy policy (§14.4 link).\n\n**`covering_set`** — one node per shard within a group. The intra-group replica selection within each shard rotates by `query_seq % rf` (plan §4 code sample). The returned set is **deduplicated** because one node may own multiple shards in the same group; searching it once captures all its shards (Meilisearch searches all its local docs in a single call).\n\n## Critical Invariant\n\nTwo different Miroir pods, given identical `Topology` + `rf` + `shard_count`, **must** compute the same `write_targets` for any given `shard_id` and the same `covering_set` modulo `query_seq` rotation. This is the property that makes the request path stateless (plan §14.4).\n\n## Acceptance (plan §8)\n\n- [ ] `write_targets` returns exactly `RG × RF` nodes (counting duplicates)\n- [ ] `write_targets` assigns one-per-group: the subset of returned nodes in group g is exactly `assign_shard_in_group(shard, group_g_nodes)`\n- [ ] `covering_set` has `|covering_set| ≤ Ng` and covers all `shard_count` shards within the chosen group\n- [ ] Two instances of `Topology` with identical content produce identical `covering_set` outputs for the same `query_seq`\n- [ ] `query_group` distribution: 10K `query_seq` values `% RG` produce uniformly distributed group choices (chi-square pass)","status":"closed","priority":0,"issue_type":"task","assignee":"delta","created_at":"2026-04-18T21:26:11.798428290Z","created_by":"coding","updated_at":"2026-04-19T04:14:55.689143427Z","closed_at":"2026-04-19T04:14:55.689022605Z","close_reason":"All three functions already implemented in router.rs:\n- write_targets (lines 40-45): flat union of assign_shard_in_group across all RG groups\n- query_group (lines 48-50): round-robin by query_seq % replica_groups \n- covering_set (lines 53-63): deduplicated node set with replica rotation\n\nAll 7 P1.3 acceptance tests pass:\n- write_targets returns RG × RF nodes\n- write_targets assigns one-per-group correctly\n- covering_set covers all shards within chosen group\n- covering_set size ≤ Ng\n- Two identical topologies produce identical covering_set outputs\n- query_group distribution is uniform (chi-square test)\n- covering_set rotates replicas by query_seq","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","phase-1"],"dependencies":[{"issue_id":"miroir-cdo.3","depends_on_id":"miroir-cdo","type":"parent-child","created_at":"2026-04-18T21:26:11.798428290Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-cdo.3","depends_on_id":"miroir-cdo.1","type":"blocks","created_at":"2026-04-18T21:26:21.555076342Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-cdo.3","depends_on_id":"miroir-cdo.2","type":"blocks","created_at":"2026-04-18T21:26:21.576939978Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-cdo.4","title":"P1.4 Result merger (global sort + offset/limit + facets + stripping)","description":"## What\n\nImplement `miroir_core::merger`:\n```rust\npub struct MergeInput {\n pub shard_hits: Vec, // one per node in covering set\n pub offset: usize,\n pub limit: usize,\n pub client_requested_score: bool,\n pub facets: Option>,\n}\npub fn merge(input: MergeInput) -> MergedSearchResult\n```\n\n## Why\n\nPlan §2 read path step 6 enumerates the exact sequence:\n1. Collect all hits with scores\n2. Sort globally descending by `_rankingScore`\n3. Apply `offset + limit` **after** merge (not per-shard)\n4. Strip `_rankingScore` from each hit if client did not request it\n5. **Always** strip `_miroir_shard` (and other reserved `_miroir_*` fields)\n6. Sum facet counts across shards\n7. Sum `estimatedTotalHits` across shards\n8. `processingTimeMs` = max across covering set\n\nThis must be a pure function — testable without a network — because it will be hit constantly and any non-determinism (e.g., HashMap iteration order affecting facet key ordering) breaks the compatibility suite.\n\n## Design Notes\n\n- Use a binary min-heap of size `offset + limit` to avoid keeping all hits in RAM when fan-out is large\n- Facet merging: `BTreeMap>` (ordered) for stable serialization\n- `estimatedTotalHits` clamp: Meilisearch caps at 1000 per shard by default — confirm whether Miroir should pass through the cap or sum and let the client see a higher number (consistent with Meilisearch single-node behavior: pass through)\n- Tie-breaking: on equal `_rankingScore`, fall back to lexicographic `primary_key` for deterministic ordering\n\n## Score Comparability Caveat (plan §2 read path, §13.5)\n\nScores are comparable across shards **only if** all nodes have identical index settings — enforced by the §13.5 two-phase broadcast. Until Phase 5 lands, assume settings are uniform and flag a warning in `Config::validate` if drift is detected.\n\n## Acceptance (plan §8 \"Result merger\")\n\n- [ ] Global sort by `_rankingScore` descending across shards\n- [ ] `offset + limit` applied **after** merge; test: 50 docs with known scores, pages of 10 reconstruct single limit=50\n- [ ] `_rankingScore` stripped when `client_requested_score=false`\n- [ ] `_miroir_shard` always stripped\n- [ ] Facet counts sum correctly including keys unique to one shard\n- [ ] `estimatedTotalHits` summed across shards\n- [ ] Stable serialization: `merge` on the same input twice produces byte-identical JSON","status":"closed","priority":0,"issue_type":"task","assignee":"charlie","created_at":"2026-04-18T21:26:11.829984535Z","created_by":"coding","updated_at":"2026-04-19T03:47:30.950232784Z","closed_at":"2026-04-19T03:47:30.950122326Z","close_reason":"Implementation complete and all tests passing (13/13). The merger module implements global sort by _rankingScore descending, offset/limit after merge, conditional _rankingScore stripping, always strips _miroir_* fields, facet aggregation, estimatedTotalHits summation, max processingTimeMs, and degraded flag. Pure function with deterministic output.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","phase-1"],"dependencies":[{"issue_id":"miroir-cdo.4","depends_on_id":"miroir-cdo","type":"parent-child","created_at":"2026-04-18T21:26:11.829984535Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-cdo.5","title":"P1.5 scatter module: covering-set construction + dispatch trait","description":"## What\n\nImplement `miroir_core::scatter` with:\n```rust\npub trait NodeClient { /* HTTP calls to a Meilisearch node */ }\npub fn plan_search_scatter(topology: &Topology, query_seq: u64, rf: usize, shard_count: u32) -> ScatterPlan\npub async fn execute_scatter(plan: ScatterPlan, client: &C, req: SearchRequest) -> Vec\n```\n\n## Why\n\n`NodeClient` is the seam between `miroir-core` (pure, no network) and `miroir-proxy` (HTTP client). Injecting it via a trait means unit tests can provide a fake client; production binds `reqwest` via the trait impl in `miroir-proxy`.\n\n`plan_search_scatter` returns the exact shard→node mapping that Phase 2 hands to `execute_scatter`. Separating the plan from execution is what makes §13.20 `/explain` cheap — the explain path generates the plan and returns it without touching any node.\n\n## Plan Structure\n\n```rust\npub struct ScatterPlan {\n pub chosen_group: u32, // query_seq % RG\n pub target_shards: Vec, // for §13.4 narrowing — initially all 0..S\n pub shard_to_node: HashMap, // resolved covering set\n pub deadline_ms: u32,\n pub hedging_eligible: bool, // reserved for §13.2 Phase 5\n}\n```\n\n## Acceptance\n\n- [ ] Plan construction is pure — no async, no I/O\n- [ ] `execute_scatter` with a mock `NodeClient` returns one `ShardHitPage` per node in the plan\n- [ ] Partial-failure handling: a failed node surfaces as `Err` on that shard; `merge` downstream applies `unavailable_shard_policy`\n- [ ] Deadline propagation: when any node exceeds `deadline_ms`, the result includes a partial-response flag","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-18T21:26:11.849030740Z","created_by":"coding","updated_at":"2026-04-19T04:22:14.446784349Z","closed_at":"2026-04-19T04:22:14.446675535Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","phase-1"],"dependencies":[{"issue_id":"miroir-cdo.5","depends_on_id":"miroir-cdo","type":"parent-child","created_at":"2026-04-18T21:26:11.849030740Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-cdo.5","depends_on_id":"miroir-cdo.3","type":"blocks","created_at":"2026-04-18T21:26:21.594739255Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-cdo.6","title":"P1.6 Property + benchmark tests for router (criterion + proptest)","description":"## What\n\n- `proptest`-based property tests for rendezvous: determinism, minimal reshuffling bounds, uniformity at various (S, Ng, RF) sizes\n- `criterion` benchmarks targeting the plan §8 goals:\n - Rendezvous assignment (64 shards, 3 nodes, 10K docs) < 1 ms total\n - Merger (1000 hits, 3 shards) < 1 ms\n\n## Why\n\nPlan §8 sets both as gates (\"A PR that increases measured search latency by > 20% over the previous release triggers a review comment\"). Having them live from Phase 1 means regression prevention starts with the first router change.\n\n## Details\n\n- Benches go in `crates/miroir-core/benches/`\n- Property tests go in `crates/miroir-core/tests/` or as `#[cfg(test)]` modules with `proptest!` macros\n- Use a `HashSet` diff to measure reshuffling; assert `|diff| <= 2 * ceil(S / (N+1))` for a node-add event\n\n## Acceptance\n\n- [ ] `cargo bench -p miroir-core` runs all criterion benches and reports timing\n- [ ] `cargo test -p miroir-core` runs property tests with 1024 cases per property (default proptest config)\n- [ ] Phase 8 CI includes `cargo bench --no-run` to compile benches on every build","status":"closed","priority":1,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:26:11.875805587Z","created_by":"coding","updated_at":"2026-04-19T03:59:44.913619571Z","closed_at":"2026-04-19T03:59:44.913255536Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","phase-1"],"dependencies":[{"issue_id":"miroir-cdo.6","depends_on_id":"miroir-cdo","type":"parent-child","created_at":"2026-04-18T21:26:11.875805587Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-cdo.6","depends_on_id":"miroir-cdo.1","type":"blocks","created_at":"2026-04-18T21:26:21.615386498Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-cdo.6","depends_on_id":"miroir-cdo.4","type":"blocks","created_at":"2026-04-18T21:26:21.629878965Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-exo","title":"Build Dockerfile + musl cross-compilation","description":"## Docker Image\n\n- `FROM scratch` + static `miroir-proxy` binary\n- Expose 7700 + 9090\n- OCI labels: source, version, revision, licenses=MIT\n- Target size < 15 MB compressed\n\n## Cargo musl build\n\n- `x86_64-unknown-linux-musl` target\n- `cargo build --release` for both `-p miroir-proxy` and `-p miroir-ctl`\n\n## Acceptance\n- Final image ≤ 15 MB compressed\n- Both binaries compile as static musl","status":"closed","priority":2,"issue_type":"task","assignee":"alpha","created_at":"2026-04-19T17:26:09.182544790Z","created_by":"coding","updated_at":"2026-04-19T17:48:35.167668213Z","closed_at":"2026-04-19T17:48:35.167602933Z","close_reason":"Multi-stage Dockerfile with musl cross-compilation: both binaries compile as static-pie, final scratch image 4.0 MB compressed. Added .dockerignore, fixed .cargo/config.toml.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","mitosis-child","mitosis-depth:1","parent-miroir-qjt"]} -{"id":"miroir-g7i","title":"Create Helm chart charts/miroir/","description":"## Helm chart `charts/miroir/`\n\nTemplates: deployment, service, headless, configmap, secret, HPA, optional PVC (CDC), StatefulSet for meilisearch, meilisearch service, optional Redis deployment, serviceaccount\n\n- `values.yaml` with dev defaults (replicas=1, SQLite, RF=1, RG=1, HPA off)\n- `values.schema.json` that rejects:\n - `miroir.replicas > 1` with `taskStore.backend: sqlite`\n - `miroir.hpa.enabled: true` without `replicas >= 2 && taskStore.backend: redis`\n - `search_ui.rate_limit.backend: local` when `miroir.replicas > 1`\n - Admin login rate-limit local backend in HA\n - `search_ui.scoped_key_rotate_before_expiry_days >= scoped_key_max_age_days`\n- `_helpers.tpl` for fully-qualified StatefulSet DNS node addresses (plan §6 ConfigMap)\n- `NOTES.txt` with next-step pointers\n\n## Acceptance\n- `helm install search charts/miroir --namespace search --wait` stands up a working single-pod cluster\n- `values.schema.json` rejections tested via `helm lint --strict` with mutating values files","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-19T17:26:09.265996602Z","created_by":"coding","updated_at":"2026-04-19T17:51:27.305879197Z","closed_at":"2026-04-19T17:51:27.305648006Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","mitosis-child","mitosis-depth:1","parent-miroir-qjt"]} -{"id":"miroir-m9q","title":"Phase 6 — Horizontal Scaling + HPA (§14)","description":"## Phase 6 Epic — Horizontal Scaling + HPA\n\nDelivers the §14 promise: **fixed per-pod envelope (2 vCPU / 3.75 GB), scale out never up**. Makes the request path strictly stateless and partitions background work across pods via one of three coordination modes.\n\n## Why This Is A Phase\n\nPlan §1 principle 8 + plan §14 are the architectural spine. Phase 2's proxy already runs on one pod; this phase makes N pods coherent. Every §13 feature's \"Scaling mode\" column in plan §14.6 gets wired up here — Phase 5's implementations have to already understand they'll run inside one of the three modes.\n\n## Scope\n\n**14.1–14.3 — Per-pod envelope**\n- `resources.requests` = 500m / 1Gi; `resources.limits` = 2000m / 3584Mi\n- Per-feature memory row validated against plan §14.2 budget\n- CPU budget per plan §14.3 (~3 kQPS/pod small responses)\n\n**14.4 — Request path HPA**\n- `autoscaling/v2` HPA on CPU 70%, memory 75%, `miroir_requests_in_flight` as `type: Pods` `AverageValue: 500`, `miroir_background_queue_depth` as `type: External` `Value: 10` (plan §14.4 note on metric types)\n- `prometheus-adapter` as a chart prerequisite when HPA is enabled\n- `values.schema.json` rejects `hpa.enabled=true` without `replicas >= 2 AND taskStore.backend = redis`\n\n**14.5 — Background coordination modes**\n- **Mode A — Shard-partitioned ownership** (anti-entropy §13.8, settings-drift check §13.5, task registry pruner, TTL sweeper §13.14, canary runner §13.18)\n- **Mode B — Leader-only lease** (reshard coordinator §13.1, rebalancer Phase 4, alias flip serializer §13.7, two-phase settings broadcast §13.5, ILM evaluator §13.17, scoped-key rotation leader §13.21)\n- **Mode C — Work-queued chunked jobs** (streaming dump import §13.9, large reshard backfill §13.1)\n- **Peer discovery** via headless Service (`miroir-headless`) + Downward API `POD_NAME`/`POD_IP`, 15s SRV refresh\n- Rendezvous over peer set for Mode A; `SET NX EX 10` renewed every 3s for Mode B\n- Job lease heartbeat every 10s with 30s timeout for Mode C\n\n**14.6 — Per-feature scaling-mode wiring** — 21 rows, each must compile against the chosen mode\n\n**14.7 — Deployment sizing matrix** — ops documentation/tooling surfacing orchestrator pod count vs. corpus × QPS tiers\n\n**14.8 — Resource-aware defaults** — every config knob's default sized for the envelope\n\n**14.9 — Resource-pressure metrics + alerts** — `miroir_memory_pressure`, `miroir_cpu_throttled_seconds_total`, `miroir_request_queue_depth`, `miroir_background_queue_depth{job_type}`, `miroir_peer_pod_count`, `miroir_leader`, `miroir_owned_shards_count`; PrometheusRule alerts\n\n**14.10 — Vertical-scaling escape valve** — documented as supported but not recommended; no implementation work, just docs\n\n## Definition of Done\n\n- [ ] Multi-pod deployment (replicas=3) — every pod independently serves requests with identical routing\n- [ ] Kill one of three pods mid-traffic — zero client-visible errors beyond retry budget (plan §8 chaos)\n- [ ] Mode A test: spin up 3 pods, anti-entropy runs exactly once per shard per interval cluster-wide\n- [ ] Mode B test: start 3 pods, exactly one holds the reshard lease at any given instant; killing it promotes another within `lease_ttl_s`\n- [ ] Mode C test: submit a 10GB dump; chunks distribute across 3 pods and HPA reacts to `miroir_background_queue_depth`\n- [ ] All §14.2 memory rows fit within 3584 MiB under realistic steady-state load\n- [ ] All §14.9 alerts present in the PrometheusRule manifest and trip under induced fault","status":"open","priority":0,"issue_type":"epic","created_at":"2026-04-18T21:21:13.549727274Z","created_by":"coding","updated_at":"2026-04-18T21:23:08.657411091Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase","phase-6"],"dependencies":[{"issue_id":"miroir-m9q","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-18T21:23:08.657393466Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-18T21:23:08.646285774Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.1","title":"P6.1 Pod resource envelope + limits/requests","description":"## What\n\nImplement pod sizing per plan §14.1 + §14.2 + §14.8:\n- Helm `deployment.yaml` sets `resources.requests = {cpu: 500m, memory: 1Gi}`\n- `resources.limits = {cpu: 2000m, memory: 3584Mi}` (plan §14.8: \"leaves headroom under 3.75 GB node limit\")\n- Config defaults sized for the envelope (§14.8 full YAML)\n\n## Why\n\nPlan §1 principle 8: \"Fixed per-pod resource envelope (2 vCPU / 3.75 GB). When aggregate workload exceeds this envelope, scale **horizontally** by adding pods, never vertically beyond the envelope.\"\n\nWithout enforced limits, a runaway per-feature cache (e.g., session_pinning.max_sessions set unreasonably high) can push a pod into OOM-kill territory, inviting HPA to spin up replacements instead of surfacing the misconfiguration.\n\n## Details\n\n**Per-feature memory rows** (plan §14.2) each need their defaults:\n\n| Component | Budget | Knob |\n|-----------|--------|------|\n| Runtime + axum | 80 MB | — |\n| HTTP/2 pools | 50 MB | `connection_pool_per_node` |\n| Req/resp buffers | 200 MB | `server.max_body_bytes`, `max_concurrent_requests` |\n| Task registry | 100 MB | `task_registry.cache_size` |\n| Idempotency | 100 MB | `idempotency.max_cached_keys` |\n| Sessions | 50 MB | `session_pinning.max_sessions` |\n| Coalescing | 50 MB | `query_coalescing.max_subscribers` |\n| Router + EWMA | 20 MB | fixed |\n| Plan cache | 20 MB | fixed |\n| Alias table | 10 MB | fixed |\n| Metrics | 50 MB | fixed |\n| Dump import buffer | 128 MB | `dump_import.memory_buffer_bytes` (only during import) |\n| Anti-entropy | 128 MB | `anti_entropy.max_read_concurrency` (only during pass) |\n| Multi-search scratch | 5 MB | `multi_search.max_queries_per_batch` |\n| Vector over-fetch | 30 MB | `vector_search.over_fetch_factor` |\n| CDC buffer | 64 MB | `cdc.buffer.memory_bytes` |\n| TTL cursor | 5 MB | — |\n| Tenant map LRU | 20 MB | `tenant_affinity.mode` |\n| Shadow tee | ~50 MB | `shadow.targets[].sample_rate` |\n| Canary state | 20 MB | `canary_runner.run_history_per_canary` |\n| Admin UI assets | 10 MB | fixed |\n| Explain cache | 10 MB | fixed |\n| Search UI assets | 10 MB | fixed |\n| Search UI rate limiter | 20 MB (Redis-backed) | — |\n| Allocator overhead | 800 MB | — |\n| **Steady-state total** | **~1.2 GB** | |\n\n**Regression budget**: add a CI check (Phase 9) that flags when steady-state under synthetic load exceeds 1.7 GB.\n\n## Acceptance\n\n- [ ] Helm rendered manifest matches the requests/limits above\n- [ ] Idle pod < 300 MB RSS on a 3-node cluster\n- [ ] Steady-state (1 kQPS across 3 Miroir pods) under 1.2 GB per pod\n- [ ] One heavy background job (dump import) adds < 500 MB to that pod's total","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:40:30.562386308Z","created_by":"coding","updated_at":"2026-04-24T03:52:34.511568451Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.1","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:34.491788821Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.1","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:34.511528203Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.2","title":"P6.2 Peer discovery via headless Service + Downward API","description":"## What\n\nImplement peer discovery per plan §14.5:\n- Helm `miroir-headless.yaml` — a headless Service with label selector on the Deployment\n- Deployment: Downward API injects `POD_NAME` + `POD_IP` as env vars\n- Each pod refreshes peer set every `peer_discovery.refresh_interval_s` (default 15s) via SRV lookup against `miroir-headless..svc.cluster.local`\n- Peer set is `Vec` where `PeerId = POD_NAME` — used by rendezvous for Mode A ownership\n\n## Why\n\nPlan §14.5: \"All three modes rely on the current peer set.\" Mode A rendezvous partitions by peer × work-item; Mode B leader election picks one peer; Mode C claim lease is by peer. Without a peer set, we'd need either a central registry (new dependency) or K8s API calls (requires RBAC + API server load).\n\nSRV-based discovery is zero-config — if headless Service exists, it just works.\n\n## Details\n\n**Manifest** (plan §14.5 + §6):\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n name: miroir-headless\nspec:\n clusterIP: None\n selector:\n app.kubernetes.io/name: miroir\n ports: [...]\n```\n\n**Env injection** (plan §14.5 \"Peer discovery\"):\n```yaml\nenv:\n- name: POD_NAME\n valueFrom: { fieldRef: { fieldPath: metadata.name } }\n- name: POD_IP\n valueFrom: { fieldRef: { fieldPath: status.podIP } }\n```\n\n**Rust side**:\n```rust\npub struct PeerSet { pub peers: Vec, pub refreshed_at: Instant }\npub async fn refresh_peers(service: &str) -> PeerSet { /* SRV lookup */ }\n```\n\n**Transient double-work** is acceptable (plan §14.5): \"15-second discovery window is harmless: anti-entropy is idempotent, settings-repair is idempotent.\"\n\n## Acceptance\n\n- [ ] 3-pod deployment: each pod sees all 3 peer names within 30s of last pod ready\n- [ ] Scale 3→5: new peers discovered within `refresh_interval_s × 2`\n- [ ] Pod eviction: crashed pod drops from peer set within `refresh_interval_s × 2`\n- [ ] `miroir_peer_pod_count` gauge matches `kube_deployment_status_replicas_ready`","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:40:30.582753605Z","created_by":"coding","updated_at":"2026-04-24T03:52:34.452143557Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.2","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:34.435726113Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.2","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:34.452099729Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.3","title":"P6.3 Mode A: shard-partitioned ownership (anti-entropy, drift, TTL, canaries, pruner)","description":"## What\n\nImplement plan §14.5 Mode A rendezvous-partitioned ownership:\n```\nowns(shard_or_item, pod) = pod == top1_by_score(hash(item || pid) for pid in peer_set)\n```\n\nApplied to:\n- §13.8 anti-entropy reconciler — each pod fingerprints/repairs owned shards\n- §13.5 settings drift checker — each pod polls subset of (index, node) settings-hash pairs\n- Task registry pruner — each pod prunes tasks it owns by `top1_by_score(hash(miroir_id || pid))`\n- §13.14 TTL sweeper — each pod sweeps owned shards\n- §13.18 canary runner — each canary ID rendezvous-owned by one pod per interval\n\n## Why\n\nPlan §14.5: \"No explicit handoff — the new owner runs the next scheduled pass. Transient double-work during a 15-second discovery window is harmless.\" Mode A is naturally horizontal (work scales with peer count) and idempotent (safe during rescheduling).\n\n## Details\n\n**Ownership function** (reuses Phase 1 `score` with item:pod keys instead of shard:node):\n```rust\npub fn owns(item: &T, self_pod: &PeerId, peers: &[PeerId]) -> bool {\n peers.iter()\n .max_by_key(|pid| score_item_peer(item, pid))\n .map_or(false, |top| top == self_pod)\n}\n```\n\n**Scheduled runs**: each Mode A worker is a tokio task with a tick interval. On tick:\n1. Refresh peer set\n2. For each eligible item, check `owns(item, self)` and process if so\n3. Record progress per-item so rescheduling mid-run resumes cleanly\n\n**Phase 5 integration**: each §13.x subsection that declared \"Mode A\" in plan §14.6 calls into this layer rather than implementing its own peer-partitioning.\n\n## Acceptance\n\n- [ ] 3 pods running anti-entropy: each shard processed exactly once per interval cluster-wide\n- [ ] Kill one pod mid-pass: its shards reassigned to other peers within `refresh_interval_s × 2`; no shard processed by two pods simultaneously beyond the 15s window\n- [ ] Unit test: `owns()` returns true for exactly one peer per item across the peer set\n- [ ] Integration: induce divergence; Mode A anti-entropy converges across 3 pods with no double-repair","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:40:30.605342882Z","created_by":"coding","updated_at":"2026-04-24T03:52:34.392757121Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.3","depends_on_id":"miroir-m9q.2","type":"blocks","created_at":"2026-04-18T21:40:36.034974102Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.3","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:34.371652015Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.3","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:34.392714857Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.3.1","title":"P6.3.a Mode A: anti-entropy rendezvous partitioning","description":"Plan §14.5 Mode A + §13.8. Each pod owns shards where top1_by_score(hash(shard_id || pid)) == self. owns() reuses miroir-cdo router primitives. Transient double-work during 15s peer-discovery window is safe (idempotent repair).","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-21T12:40:14.718296370Z","created_by":"coding","updated_at":"2026-04-24T03:52:33.082835818Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.3.1","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:33.063572236Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.3.1","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:33.082807392Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.3.2","title":"P6.3.b Mode A: settings drift check partitioning","description":"Plan §14.5 Mode A + §13.5. Each pod polls a subset of (index, node) settings-hash pairs by rendezvous. Auto-repair on mismatch; idempotent.","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-21T12:40:14.751329830Z","created_by":"coding","updated_at":"2026-04-24T03:52:33.023066781Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.3.2","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:33.001905717Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.3.2","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:33.023027759Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.3.3","title":"P6.3.c Mode A: task registry pruner partitioning","description":"Plan §14.5 Mode A + §4 tasks table. Replace Phase 3 single-pod pruner (miroir-r3j.6) with rendezvous by hash(miroir_id || pid). Keeps tasks table bounded without duplicate deletes across pods.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:40:14.781604863Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.598308692Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.3.3","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:35.582310516Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.3.3","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:35.598268418Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.3.4","title":"P6.3.d Mode A: TTL sweeper partitioning","description":"Plan §14.5 Mode A + §13.14. Each pod sweeps only its rendezvous-owned shards; no duplicate filter-deletes. Per-pod cursor in jobs table so resume is idempotent.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:40:14.811629365Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.549780001Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.3.4","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:35.534118687Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.3.4","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:35.549754280Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.3.5","title":"P6.3.e Mode A: canary runner partitioning","description":"Plan §14.5 Mode A + §13.18. Each canary ID rendezvous-owned by one pod per interval. Prevents duplicate runs cluster-wide.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:40:14.849999359Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.499159731Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.3.5","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:35.482038362Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.3.5","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:35.499135608Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.4","title":"P6.4 Mode B: leader-only singleton coordinator (reshard, rebalance, alias flip, 2PC, ILM, scoped-key rotation)","description":"## What\n\nImplement plan §14.5 Mode B leader-only lease:\n- SQLite: advisory lock row in `leader_lease` (plan §4) — the lease holder is recorded so recovery reads the last committed phase state\n- Redis: `SET NX EX 10` renewed every 3s\n- Leader-loss mid-operation: pause; new leader reads persisted phase state and resumes at the last committed phase boundary\n- All Mode B operations are designed to be **idempotent** and safe to resume at phase boundaries\n\nLease scopes (plan §14.6):\n- §13.1 reshard coordinator: `reshard:`\n- Phase 4 rebalancer: `rebalance:` (or global `rebalance`)\n- §13.7 alias flip serializer: `alias_flip:`\n- §13.5 two-phase settings broadcast: `settings_broadcast:`\n- §13.17 ILM evaluator: `ilm`\n- §13.21 scoped-key rotation: `search_ui_key_rotation:`\n\n## Why\n\nPlan §14.5: \"Leader loss mid-operation causes a pause; the new leader reads the persisted phase state from the task store and resumes from the last committed phase. All operations are idempotent by design and safe to resume at any phase boundary.\"\n\nWithout lease-based coordination, two pods could each run a reshard on the same index simultaneously → double shadow creation, conflicting alias flips, data corruption.\n\n## Details\n\n**Lease renewal**: every 3s (`leader_election.renew_interval_s`); TTL 10s (`leader_election.lease_ttl_s`). If renewal fails, leader gives up voluntarily to reduce split-brain.\n\n**Phase state persistence**: each Mode B operation persists enough state after each phase so resumption picks up where the dead leader left off:\n- Reshard: current phase ∈ {shadow, backfill, verify, swap, cleanup} + per-shard cursor\n- 2PC broadcast: current phase ∈ {propose, verify, commit} + per-node ACK list\n- ILM: per-policy next-check-time + in-flight rollover state\n\n**Config**:\n```yaml\nleader_election:\n enabled: true # auto-true when replicas > 1\n lease_ttl_s: 10\n renew_interval_s: 3\n```\n\n**SQLite substitute**: for single-pod dev, the `leader_lease` row is still written (so recovery can read the last committed phase state after a crash); lease semantics reduced to \"always-leader.\"\n\n**Metrics**: `miroir_leader` gauge (1 if this pod is leader, 0 otherwise).\n\n## Acceptance\n\n- [ ] 3 pods: exactly one is leader at any instant; killing it promotes another within `lease_ttl_s`\n- [ ] Kill the leader during reshard phase 3 (verify); new leader resumes at phase 3, not phase 1\n- [ ] Kill the leader during 2PC phase 2 (verify); new leader resumes verify without re-applying phase 1\n- [ ] `miroir_leader` sum across all pods is always 1 (or 0 transiently during failover)","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:40:30.638856024Z","created_by":"coding","updated_at":"2026-04-24T03:52:34.331983275Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.4","depends_on_id":"miroir-m9q.2","type":"blocks","created_at":"2026-04-18T21:40:36.064226657Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.4","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:34.315222996Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.4","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:34.331938351Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.4.1","title":"P6.4.a Reshard coordinator lease (Mode B: scope 'reshard:')","description":"Plan §14.5 Mode B. One-reshard-per-index invariant via leader_lease row scope=reshard:. Leader loss mid-operation: persist {phase, per-shard cursor}; new leader reads last committed phase and resumes. Integrates with miroir-uhj.1 phase state machine.","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-21T12:39:46.286231717Z","created_by":"coding","updated_at":"2026-04-24T03:52:33.299513728Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.4.1","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:33.282149789Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.4.1","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:33.299488613Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.4.2","title":"P6.4.b Rebalancer lease (scope 'rebalance:' or global 'rebalance')","description":"Plan §14.5 Mode B. Phase 4 rebalancer already uses advisory lock — port to leader_lease row. Persist per-shard migration cursor so leader-loss mid-migration resumes idempotently via PK-level Meilisearch writes.","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-21T12:39:46.334162908Z","created_by":"coding","updated_at":"2026-04-24T03:52:33.242300872Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.4.2","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:33.219805398Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.4.2","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:33.242276424Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.4.3","title":"P6.4.c Alias flip serializer (scope 'alias_flip:')","description":"Plan §14.5 Mode B. Atomic PUT /_miroir/aliases/{name} serialized by lease per alias name. Prevents two pods concurrently flipping the same alias. history append-only. Integrates with miroir-uhj.7.","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-21T12:39:46.376988183Z","created_by":"coding","updated_at":"2026-04-24T03:52:33.184857371Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.4.3","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:33.167584464Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.4.3","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:33.184761050Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.4.4","title":"P6.4.d Two-phase settings broadcast coordinator (scope 'settings_broadcast:')","description":"Plan §14.5 Mode B. §13.5 propose/verify/commit must run one-at-a-time per index. Leader issues PATCH, collects acks, verifies, commits. Persist phase state after each phase for leader-failover resume.","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-21T12:39:46.426404485Z","created_by":"coding","updated_at":"2026-04-24T03:52:33.131719237Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.4.4","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:33.115582267Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.4.4","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:33.131694386Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.4.5","title":"P6.4.e ILM evaluator lease (scope 'ilm')","description":"Plan §14.5 Mode B. §13.17 daily rollover policy evaluator runs on exactly one pod. Serializes index create + atomic alias flip + safety-lock-guarded index delete. Leader-loss recovery via persisted per-policy next-check-time.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:39:46.461064651Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.710896332Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.4.5","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:35.694350994Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.4.5","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:35.710854464Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.4.6","title":"P6.4.f Search UI scoped-key rotation leader (scope 'search_ui_key_rotation:')","description":"Plan §13.21 + §14.5 Mode B. Leader mints new scoped key, updates miroir:search_ui_scoped_key: hash, runs revocation safety gate against per-pod beacons, drains scoped_key_rotation_drain_s before DELETE /keys/{old}.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:39:46.489863562Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.656136624Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.4.6","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:35.638618057Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.4.6","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:35.656086174Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.5","title":"P6.5 Mode C: work-queued chunked jobs (dump import, reshard backfill)","description":"## What\n\nImplement plan §14.5 Mode C work-queued chunked jobs:\n- `jobs` table (Phase 3) with states `queued | in_progress | completed | failed`\n- Any pod can `claim_job(pod_id)` — atomic compare-and-swap `claimed_by IS NULL → claimed_by = pod_id`\n- Claim TTL: `claim_expires_at`, heartbeat every 10s, timeout 30s — pod loss → claim expires → another picks up\n- Large jobs **split into chunks** on input boundaries by the first pod that picks them up\n- Per-chunk progress persisted so crashed claims resume at last committed offset (idempotent via primary keys)\n\nApplied to:\n- §13.9 streaming dump import — chunks on NDJSON line boundaries, `chunk_size_bytes` default 256 MiB\n- §13.1 reshard backfill — partitions by shard-id range\n\n## Why\n\nPlan §14.5: \"Heavy streaming operations can exceed a single pod's envelope.\" A 500 GB dump is easily 10× a pod's memory budget — must chunk.\n\nPlan §14.4 HPA: `miroir_background_queue_depth` gauge → HPA scales out when backlog grows; scales back in when drained.\n\n## Details\n\n**Chunking**: first pod that picks up a large job inspects the input, computes split points, and re-enqueues per-chunk jobs. Original job transitions to `in_progress` with progress = \"splitting\" → \"delegated\" when chunks enqueued.\n\n**Claim heartbeat**: `UPDATE jobs SET claim_expires_at = now + 30s WHERE id = ? AND claimed_by = ?` — succeeds only if we still hold it. Pod crash → no heartbeat → next lease expiry releases claim.\n\n**Idempotent resume**: chunks record `{bytes_processed, docs_routed, last_cursor}`. A resumed chunk starts at `last_cursor` and re-writes docs (PK-idempotent at Meilisearch level → no dupes).\n\n**Queue depth metric**: `miroir:jobs:_queued` set; `SCARD miroir:jobs:_queued` = `miroir_background_queue_depth`. Fed to HPA as external metric per plan §14.4.\n\n**Config** tied to §13.9:\n```yaml\ndump_import:\n chunk_size_bytes: 268435456 # 256 MiB per §14.5 Mode C chunk-parallel coordinator\n```\n\n## Acceptance\n\n- [ ] 1 GB dump: first pod splits into 4× 256 MiB chunks; 3 pods claim 3 of 4 chunks in parallel; queue drains\n- [ ] Kill a claimant mid-chunk: claim expires in 30s; another pod picks up and resumes at `last_cursor`\n- [ ] HPA on `miroir_background_queue_depth > 10` triggers scale-up during the burst; scale-down once empty\n- [ ] Two concurrent dumps: chunks from both interleave in claims; neither starves","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:40:30.654570336Z","created_by":"coding","updated_at":"2026-04-24T03:52:34.282914752Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.5","depends_on_id":"miroir-m9q.2","type":"blocks","created_at":"2026-04-18T21:40:36.099899160Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.5","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:34.267228037Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.5","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:34.282890157Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.5.1","title":"P6.5.a Mode C: streaming dump import chunked jobs","description":"Plan §14.5 Mode C + §13.9. Large .dump files split on NDJSON boundaries at chunk_size_bytes (default 256 MiB). First pod to pick up computes splits, re-enqueues per-chunk jobs. Claim heartbeat every 10s, 30s timeout, per-chunk {bytes_processed, docs_routed, last_cursor} persisted for idempotent resume.","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-21T12:40:14.880032522Z","created_by":"coding","updated_at":"2026-04-24T03:52:32.958992022Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.5.1","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:32.937685962Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.5.1","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:32.958952473Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.5.2","title":"P6.5.b Mode C: reshard backfill chunked jobs","description":"Plan §14.5 Mode C + §13.1. Reshard backfill partitioned by shard-id range; each chunk a job. Idempotent resume via PK-level dedup at Meilisearch. HPA scales on miroir_background_queue_depth.","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-21T12:40:14.907525098Z","created_by":"coding","updated_at":"2026-04-24T03:52:22.771411572Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.5.2","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:22.743355066Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.5.2","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:22.771363780Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.6","title":"P6.6 HPA spec + prometheus-adapter + schema validation","description":"## What\n\nShip the HPA spec (plan §14.4):\n```yaml\napiVersion: autoscaling/v2\nkind: HorizontalPodAutoscaler\nspec:\n minReplicas: 2\n maxReplicas: 24\n behavior:\n scaleDown: { stabilizationWindowSeconds: 300 }\n scaleUp: { stabilizationWindowSeconds: 30 }\n metrics:\n - Resource cpu 70%\n - Resource memory 75%\n - Pods miroir_requests_in_flight AverageValue: 500\n - External miroir_background_queue_depth Value: 10\n```\n\nChart preconditions enforced via `values.schema.json`:\n- `hpa.enabled: true` requires `replicas >= 2 AND taskStore.backend: redis`\n- `prometheus-adapter` (or equivalent) as a documented prerequisite when HPA is enabled\n\n## Why\n\nPlan §14.4: \"`miroir_requests_in_flight` is **per-pod** and uses `type: Pods`. `miroir_background_queue_depth` is **global** and must use `type: External` with `type: Value`.\" Getting the metric type wrong produces a pathological HPA that monotonically scales to `maxReplicas`.\n\n## Details\n\n**Per-workload-tier min/max** (plan §14.7):\n| Peak QPS | minReplicas | maxReplicas |\n|---|---|---|\n| ≤ 500 | 2 | 3 |\n| ≤ 2k | 2 | 4 |\n| ≤ 5k | 4 | 8 |\n| ≤ 20k | 8 | 12 |\n| ≤ 100k | 12 | 24 |\n\nDefault values.yaml ships the ≤ 5k tier; operators override per workload.\n\n**prometheus-adapter config**: add a ConfigMap-defined `rules.externalMetrics` entry mapping `miroir_background_queue_depth` to the external metrics API. This is NOT shipped by the Miroir chart (operators install prometheus-adapter separately); the chart's `NOTES.txt` calls it out.\n\n**Stabilization windows**: scale-up fast (30s), scale-down slow (300s). Avoids pod flapping.\n\n## Acceptance\n\n- [ ] `helm lint --strict` with `hpa.enabled: true + replicas: 1` → fails with schema error\n- [ ] `helm lint --strict` with `hpa.enabled: true + replicas: 2 + backend: sqlite` → fails\n- [ ] HPA in a kind cluster: induce CPU load → scales up within 30s; load drops → scales down after 300s\n- [ ] External metric binding: `miroir_background_queue_depth` visible via `kubectl get --raw /apis/external.metrics.k8s.io/v1beta1/...`","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:40:30.676597441Z","created_by":"coding","updated_at":"2026-04-24T03:52:34.230412558Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.6","depends_on_id":"miroir-m9q.4","type":"blocks","created_at":"2026-04-18T21:40:36.140248526Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.6","depends_on_id":"miroir-m9q.5","type":"blocks","created_at":"2026-04-18T21:40:36.163063693Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.6","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:34.212979405Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.6","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:34.230367115Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-m9q.7","title":"P6.7 Resource-pressure metrics + alerts (§14.9)","description":"## What\n\nRegister the plan §14.9 resource-pressure metrics:\n- `miroir_memory_pressure` gauge (0=ok, 1=warn >75%, 2=critical >90%)\n- `miroir_cpu_throttled_seconds_total` counter (cgroup throttling)\n- `miroir_request_queue_depth` gauge\n- `miroir_background_queue_depth{job_type}` gauge\n- `miroir_peer_pod_count` gauge\n- `miroir_leader` gauge\n- `miroir_owned_shards_count` gauge\n\nAnd the associated `PrometheusRule` alerts (plan §14.9).\n\n## Why\n\nThese surface under-scaling BEFORE user-visible impact. `miroir_memory_pressure` + `MiroirMemoryPressure` alert give operators (and HPA) a leading indicator instead of waiting for OOM-kill.\n\n## Details\n\n**cgroup reads**: on Linux, read `/sys/fs/cgroup/cpu.stat` (cgroup v2) or `/sys/fs/cgroup/cpu/cpu.stat` (v1) for `nr_throttled`/`throttled_time`. Convert throttled_time nanoseconds → seconds for the counter.\n\n**Memory pressure gauge**: read `/sys/fs/cgroup/memory.current` + `memory.max`; compute utilization; map to 0/1/2 per threshold.\n\n**PrometheusRule**:\n```yaml\n- alert: MiroirMemoryPressure\n expr: miroir_memory_pressure >= 2\n for: 5m\n- alert: MiroirRequestQueueBacklog\n expr: miroir_request_queue_depth > 500\n for: 2m\n- alert: MiroirBackgroundJobBacklog\n expr: miroir_background_queue_depth > 100\n for: 10m\n- alert: MiroirPeerDiscoveryGap\n expr: miroir_peer_pod_count < kube_deployment_status_replicas_ready{deployment=\"miroir\"}\n for: 2m\n- alert: MiroirNoLeader\n expr: sum(miroir_leader) == 0\n for: 1m\n```\n\n## Acceptance\n\n- [ ] All 7 metrics present on `:9090/metrics`\n- [ ] `miroir_memory_pressure` reports 2 when artificial allocation pushes RSS > 90% of limit\n- [ ] `MiroirNoLeader` fires after killing the leader without replacement within 1 min\n- [ ] `MiroirPeerDiscoveryGap` fires if headless Service misconfigured","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:40:30.711963985Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.545683046Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.7","depends_on_id":"miroir-mkk","type":"blocks","created_at":"2026-04-24T03:52:37.529425637Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.7","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:37.545645558Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-mkk","title":"Phase 4 — Topology Operations (rebalance, add/remove node + group, drain)","description":"## Phase 4 Epic — Topology Operations\n\nMakes the cluster *elastic*: operators can add or remove nodes within a group (capacity scaling) or add/remove entire replica groups (throughput scaling) without a full reindex and without downtime.\n\n## Why This Matters\n\nPlan §2 \"Topology changes\" and §4 \"Rebalancer\" together are **the** operational differentiator. Without this phase, Miroir is a static sharder — useful but not production-grade. Elasticity is what justifies the complexity of the whole system.\n\nPlan §15 Open Problem 1 (dual-write race) is partially mitigated by careful sequencing here and fully closed by §13.8 anti-entropy in Phase 5. Getting the sequencing right here means Phase 5's reconciler is a safety net, not the primary correctness mechanism.\n\n## Scope\n\n**Node addition (within a group; plan §2 \"Adding a node\")**\n\n1. Assign new node to a group; mark `joining`\n2. Recompute assignments — ~S/(Ng+1) shards move\n3. Dual-write: new inbound writes for affected shards go to **both** old owner and new node\n4. Background migration per shard: `GET /indexes/{uid}/documents?filter=_miroir_shard={id}&limit=1000&offset=...` → write each page to new node\n5. Mark `active`; stop dual-write; `POST /indexes/{uid}/documents/delete` with `filter=_miroir_shard={id}` on old owner\n\n**Replica-group addition (plan §2 \"Adding a new replica group\")** — mark `initializing`, background-sync from any healthy group using the same `_miroir_shard` filter, then flip to `active` and start routing queries.\n\n**Node removal (plan §2 \"Removing a node\")** — mark `draining`, recompute, migrate ~RF/Ng fraction to survivors, mark `removed`, operator deletes PVC.\n\n**Group removal (plan §2 \"Removing a replica group\")** — mark `draining`, stop routing queries; no data migration (other groups hold the docs); decommission.\n\n**Unplanned node failure (plan §2 \"Node failure\")** — mark `failed`; surviving intra-group replicas cover if RF>1; cross-group fallback if RF=1; schedule background replication to restore RF.\n\n**Admin API** (plan §4 admin table) — `POST /_miroir/nodes`, `DELETE /_miroir/nodes/{id}`, `POST /_miroir/nodes/{id}/drain`, `POST /_miroir/rebalance`, `GET /_miroir/rebalance/status`.\n\n## Design Notes\n\n- Relies on `_miroir_shard` being `filterable` on every node — set by Phase 2 index-create broadcast\n- Only one rebalance at a time per index (advisory lock → Phase 6 Mode B leader lease)\n- Chunked migration bounded by `rebalancer.max_concurrent_migrations` (default 4) to stay under the per-pod 3.75 GB envelope\n- Migration progress reported via `GET /_miroir/rebalance/status` and `miroir_rebalance_*` metrics (§10)\n- No full-corpus scans ever — the `_miroir_shard` filter is the key primitive; any code path that enumerates \"all docs\" is a bug\n\n## Open Problem Closure\n\nPlan §15 #1 — dual-write cutover race: document the exact sequencing here and note that §13.8 anti-entropy is the guaranteed safety net on the next pass.\n\n## Definition of Done\n\n- [ ] Chaos test: add a node mid-indexing — every doc remains readable; no duplicates on a subsequent search\n- [ ] Chaos test: drain a node while queries are in flight — zero client-visible failures; `X-Miroir-Degraded` absent or transient only\n- [ ] Chaos test: add a replica group while queries are in flight — existing groups unaffected; new group starts serving reads only after sync completes\n- [ ] Rebalance of a 3→4 node cluster moves ≤ 2×(1/4) of docs (optimal per plan §8 benches)\n- [ ] Restart a killed node mid-rebalance — rebalance pauses + resumes; no data loss","status":"open","priority":0,"issue_type":"epic","created_at":"2026-04-18T21:19:53.993012197Z","created_by":"coding","updated_at":"2026-04-18T21:23:08.609321350Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase","phase-4"],"dependencies":[{"issue_id":"miroir-mkk","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-18T21:23:08.595905334Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-mkk","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-18T21:23:08.609300009Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-mkk.1","title":"P4.1 Rebalancer background worker + advisory lock","description":"## What\n\nImplement the rebalancer as a background Tokio task (plan §4 \"Rebalancer\"):\n- Advisory lock — only one Miroir instance runs the rebalancer at a time (Phase 6 §14.5 Mode B replaces with leader lease)\n- Reacts to topology change events (node add/drain/fail/recover) from the admin API + health checker\n- Computes affected shards (the `~S/(Ng+1)` or `~RF/Ng` delta) using the Phase 1 router\n- Drives the migration state machine for each affected shard\n- Updates `miroir_rebalance_in_progress`, `miroir_rebalance_documents_migrated_total`, `miroir_rebalance_duration_seconds` (plan §10)\n\n## Why\n\nThe rebalancer is the orchestrator of all Phase 4 operations. Everything else in this phase is a subroutine called by this worker. Keeping it as a dedicated task — rather than inline in admin handlers — means a slow migration doesn't block admin API responses and a crash restarts cleanly from the task-store state.\n\n## Details\n\n**State machine per-shard**:\n```\nIdle → DualWriteStarted → MigrationInProgress → MigrationComplete → DualWriteStopped → OldReplicaDeleted → Idle\n```\n\n**Concurrency bound**: `rebalancer.max_concurrent_migrations` (default 4) to stay within plan §14.2 memory budget for migration buffers.\n\n**Progress persistence**: per-shard cursor in `jobs` table (Phase 3) so a pod restart resumes at the last committed offset. Idempotent per primary key (same doc re-written on resume is no-op at Meilisearch level).\n\n**Cancellation**: an admin API call can pause (not delete) an in-progress rebalance; resuming picks up at the persisted cursor.\n\n## Acceptance\n\n- [ ] Advisory lock: two pods running the rebalancer simultaneously produce 0 duplicate migrations (enforced via the `leader_lease` row for scope `rebalance:`)\n- [ ] Progress persistence: kill the pod mid-migration; another takes over within lease TTL and completes without starting over\n- [ ] Metrics tick: `miroir_rebalance_documents_migrated_total` monotonically increases; `_duration_seconds` histogram records per-shard migration time","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:31:43.768256172Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.102679261Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-4"],"dependencies":[{"issue_id":"miroir-mkk.1","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:35.102654477Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-mkk.1","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:35.085409777Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-mkk.2","title":"P4.2 Node addition: dual-write + paginated shard migration","description":"## What\n\nImplement the node-addition flow from plan §2 \"Adding a node to an existing group\":\n1. Admin API: `POST /_miroir/nodes` body `{\"id\": \"meili-N\", \"address\": \"...\", \"replica_group\": G}`\n2. Mark `joining`\n3. Recompute assignments — `affected_shards` where `meili-N` enters the top-RF within group G\n4. **Dual-write**: new inbound writes for affected shards go to **both** old owner and new node (idempotent — Meilisearch PUT semantics handle dupes via primary key)\n5. For each affected shard, background migration via the shard-filter primitive (plan §4):\n ```\n GET /indexes/{uid}/documents?filter=_miroir_shard={shard_id}&limit=1000&offset=0\n GET /indexes/{uid}/documents?filter=_miroir_shard={shard_id}&limit=1000&offset=1000\n ... until exhausted\n ```\n6. Write each page to the new node (docs already carry `_miroir_shard`)\n7. Mark `active`; stop dual-write\n8. Delete migrated shard from old node: `POST /indexes/{uid}/documents/delete {\"filter\": \"_miroir_shard = {shard_id}\"}`\n9. Documents on unaffected shards never touched\n\n## Why\n\nPlan §1 principle 4 (RF-configurable redundancy) + §2 \"Three independent scaling dimensions\" depend on this. The `_miroir_shard` filter primitive is what makes migration move only `~total_docs/(N+1)` docs instead of `total_docs` — a 10–100× reduction in I/O vs. a naive \"copy everything then diff\" approach.\n\n## Details\n\n**Dual-write durability invariant**: between steps 4 and 7, every accepted write for the affected shards lands on both old and new. If dual-write is skipped while migration is running, writes arriving at that exact moment may land only on the old owner and be lost when step 8 deletes. Plan §15 Open Problem 1 is the remaining race; §13.8 anti-entropy (Phase 5) is the safety net.\n\n**Pagination cursor**: `offset` is the simplest, but Meilisearch `limit + offset` has an internal cap (default 1000 + 0 → max ~20 for safe). Configure `pagination.maxTotalHits` per-node at index creation to allow deep pagination (safe: we're just iterating our own injected shard).\n\n**Per-page batch**: `rebalancer.migration_batch_size` (default 1000) — one page read + one page write per cycle.\n\n**Fail-open behavior**: if the source node becomes unavailable mid-migration, the rebalancer pauses this shard; other shards continue. When source comes back, resume.\n\n## Acceptance\n\n- [ ] Integration test: 3-node → 4-node migration, 10K docs, each doc still retrievable by ID after migration\n- [ ] Chaos: toggle writes on/off during migration; dual-write window catches all late writes\n- [ ] Performance: migrating `~S/(Ng+1)` shards moves ≤ `total_docs / (Ng+1) × 1.1` docs (10% slack for dual-write dupes)\n- [ ] The old node is not queried for the migrated shards after step 8 (verified via log inspection)","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:31:43.790167851Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.050747599Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-4"],"dependencies":[{"issue_id":"miroir-mkk.2","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:35.050706675Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-mkk.2","depends_on_id":"miroir-mkk.1","type":"blocks","created_at":"2026-04-18T21:31:48.930624028Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-mkk.2","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:35.030328431Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-mkk.3","title":"P4.3 Node removal (drain): migrate off + delete PVC handoff","description":"## What\n\nImplement `POST /_miroir/nodes/{id}/drain` + `DELETE /_miroir/nodes/{id}` (plan §2 \"Removing a node\"):\n1. Mark `draining`; stop routing writes for its affected shards to it\n2. Recompute assignments — affected shards reassigned to surviving nodes in the same group\n3. Background migration: copy affected shards to new owners via the `_miroir_shard` filter primitive\n4. Mark `removed`\n5. `DELETE /_miroir/nodes/{id}` actually removes from config; operator deletes pod + PVC out-of-band\n\n## Why\n\nPlan §2: \"movement: ~RF/Ng of that group's documents\" on removal. The drain API decouples \"stop taking writes\" (immediate) from \"delete the pod\" (operator decision) — gives operators room to verify before committing to hardware loss.\n\n## Details\n\n**Order matters**: drain → remove. `drain` is reversible (mark `active` again); `remove` is not. CLI (`miroir-ctl node drain meili-2` per plan §11) should pause and await confirmation before the remove step.\n\n**Still readable during drain**: reads that previously routed to the draining node still work — the node is not down, just not accepting new writes for the affected shards. Read traffic naturally drifts to the replacement replica via Phase 1 `covering_set` intra-group rotation.\n\n**Safety check**: refuse drain if it would drop a shard below RF=1 in its group AND the group has no healthy peer group to fall back to. Require `--force` to override.\n\n**Post-drain verification**: query `GET /indexes/{uid}/documents?filter=_miroir_shard={s}&limit=1` against the drained node — should return 0 results for every shard before `remove` is permitted.\n\n## Acceptance\n\n- [ ] 3-node RF=2 group: drain node-1; searches still succeed with zero degraded responses\n- [ ] After drain completes, `GET /indexes/{uid}/documents?filter=_miroir_shard={s}&limit=1` on node-1 returns 0 for every shard\n- [ ] `remove` without prior `drain` → 409 conflict with a message pointing at `drain` first\n- [ ] `--force` drain that would drop a shard to 0 replicas surfaces a loud warning before proceeding","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:31:43.815997915Z","created_by":"coding","updated_at":"2026-04-24T03:52:34.994667129Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-4"],"dependencies":[{"issue_id":"miroir-mkk.3","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:34.994640734Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-mkk.3","depends_on_id":"miroir-mkk.1","type":"blocks","created_at":"2026-04-18T21:31:48.943066166Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-mkk.3","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:34.978878696Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-mkk.4","title":"P4.4 Replica group addition: initializing → active","description":"## What\n\nImplement the \"Adding a new replica group\" flow from plan §2:\n1. Provision new nodes; assign `replica_group: G_new` in config\n2. Mark new group `initializing`; queries NOT routed here\n3. Background sync: for each shard, copy all docs from **any** healthy existing group to the new group's nodes via `filter=_miroir_shard={id}` pagination; new inbound writes already fan out to the new group immediately\n4. When all shards synced, mark group `active` — queries begin routing in round-robin\n5. Existing groups continue serving queries throughout (zero read interruption)\n\n## Why\n\nPlan §2 \"Adding a new replica group (throughput scaling)\": adding a group multiplies query capacity without touching existing groups' data. This is the primary \"we need more search QPS\" lever. Unlike intra-group rebalance which moves a subset, group-add **copies** every shard to the new group — so the I/O is proportional to total corpus size, not `1/(Ng+1)`.\n\n## Details\n\n**Source group selection**: round-robin across existing `active` groups to spread read load during sync. Per-shard picks a different source so one group isn't hammered.\n\n**Write fan-out during sync**: new group already receives writes from step 3 onward. This is the durability guarantee — only the backfill window of historical data is transient.\n\n**Progress tracking**: per-shard cursor in `jobs` table; can be paused/resumed per Phase 6 Mode C.\n\n**Verification before `active`**: `GET /indexes/{uid}/stats` against new group → docs count within 0.1% of source group (allows for writes landing during sync). If higher variance, delay the flip and investigate.\n\n## Acceptance\n\n- [ ] Integration test: RG=1 → RG=2; during sync, query throughput on original group unchanged (no regression)\n- [ ] After `active`, queries distribute round-robin between the two groups (verified via per-group metrics)\n- [ ] Mid-sync write test: 100 writes landing during the backfill window are all present on both groups when sync completes\n- [ ] Failed sync (source group becomes unavailable mid-copy) pauses without corrupting new group; resumes when source returns","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:31:43.859158013Z","created_by":"coding","updated_at":"2026-04-24T03:52:34.946295587Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-4"],"dependencies":[{"issue_id":"miroir-mkk.4","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:34.946268111Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-mkk.4","depends_on_id":"miroir-mkk.1","type":"blocks","created_at":"2026-04-18T21:31:48.961576914Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-mkk.4","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:34.926855787Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-mkk.5","title":"P4.5 Group removal + unplanned node failure","description":"## What\n\nTwo related flows from plan §2:\n\n**Removing a replica group** (decommission a query pool):\n1. Mark group `draining` — queries stop routing immediately\n2. Nodes can be decommissioned; no data migration needed (other groups hold the docs)\n3. Remove nodes from config; operator deletes pods + PVCs\n\n**Unplanned node failure**:\n1. Health check detects failure → mark `failed`, stop routing writes to it\n2. If RF > 1 within the group: surviving replicas serve reads — no immediate migration\n3. For reads: if failed node's shards have no intra-group RF replica, fall back to a healthy group for those shards\n4. Schedule background replication to restore RF within the group; degrade to cross-group fallback until restored\n\n## Why\n\nPlan §2: \"Changes to one group do not affect other groups' data or query routing.\" Group-removal is instant (no data movement) — lets operators shed throughput capacity without a migration window. Unplanned node failure is the most time-sensitive case: readers must not see errors; RF-restore runs in the background.\n\n## Details\n\n**Group-removal preconditions**: refuse to remove a group if it's the last group holding a shard (would be data loss). Require `--force` and document the risk.\n\n**Failure detection**: plan §4 config:\n```yaml\nhealth:\n interval_ms: 5000\n timeout_ms: 2000\n unhealthy_threshold: 3 # 3 consecutive failures → mark degraded\n recovery_threshold: 2 # 2 consecutive OKs → mark healthy again\n```\n\n**Cross-group fallback**: Phase 1 `covering_set` already deterministic per-request; the fallback is a per-shard \"if intra-group has none, check other groups\" decision **inside** the scatter planner (Phase 2).\n\n**RF-restore**: similar to P4.2 node addition but for an existing node that lost its data — re-run `_miroir_shard` filter migration from the best intra-group source.\n\n## Acceptance\n\n- [ ] Remove a group with healthy peer groups → queries route away within one `query_seq` tick; no read errors\n- [ ] `--force`-remove the last group holding shard S → loud warning; operator must re-type the index UID to confirm\n- [ ] RF=2 group with 1 node killed → reads succeed on remaining replica; `X-Miroir-Degraded` absent\n- [ ] RF=1 group with 1 node killed → cross-group fallback kicks in; `X-Miroir-Degraded` absent if fallback succeeds\n- [ ] Restored node re-hydrates from a peer replica within its group; `miroir_rebalance_in_progress` transitions 0→1→0","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:31:43.887649468Z","created_by":"coding","updated_at":"2026-04-24T03:52:34.891433334Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-4"],"dependencies":[{"issue_id":"miroir-mkk.5","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:34.891392278Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-mkk.5","depends_on_id":"miroir-mkk.1","type":"blocks","created_at":"2026-04-18T21:31:48.981335608Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-mkk.5","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:34.871041251Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-mkk.6","title":"P4.6 Admin API for topology ops: /_miroir/nodes + /_miroir/rebalance","description":"## What\n\nPlan §4 admin API endpoints for topology (wrap the rebalancer flows):\n- `POST /_miroir/nodes` — add node (P4.2)\n- `DELETE /_miroir/nodes/{id}` — drain + remove\n- `POST /_miroir/nodes/{id}/drain` — drain only (P4.3, plan §6 \"Scaling\" scale-down)\n- `POST /_miroir/rebalance` — manually trigger rebalance (e.g., after config-only topology tweak)\n- `GET /_miroir/rebalance/status` — current progress; returned shape includes per-shard phase + `miroir_task_id` for each migration batch\n\n## Why\n\nThese endpoints are the **operator surface**. Everything in §11 \"Common operations with miroir-ctl\" maps to these; the Admin UI §13.19 topology tab is a visual wrapper around the same endpoints. Keeping them REST-shaped rather than ad-hoc makes `miroir-ctl` a thin wrapper and the Admin UI trivial.\n\n## Details\n\n**Body shape for `POST /_miroir/nodes`**:\n```json\n{\n \"id\": \"meili-4\",\n \"address\": \"http://meili-4.search.svc:7700\",\n \"replica_group\": 0\n}\n```\n\n**Response**: `202 Accepted` with a `miroir_task_id` (the rebalance is async). Client polls `/tasks/{mtask}` for terminal status.\n\n**`GET /_miroir/rebalance/status`** returns:\n```json\n{\n \"in_progress\": true,\n \"triggered_by\": \"POST /_miroir/nodes\",\n \"operation_id\": \"reb-1234\",\n \"started_at\": \"2026-04-18T20:00:00Z\",\n \"phases\": [\n {\"shard\": 12, \"state\": \"MigrationInProgress\", \"pct_complete\": 42, \"source\": \"meili-0\", \"destination\": \"meili-4\"},\n ...\n ],\n \"overall_pct_complete\": 38\n}\n```\n\n**Authentication**: admin-key only (plan §5 bearer dispatch rule 2).\n\n## Acceptance\n\n- [ ] `curl -X POST -H \"Authorization: Bearer $ADMIN_KEY\" .../_miroir/nodes -d '{\"id\":\"meili-4\",\"address\":\"http://...\",\"replica_group\":0}'` returns 202 + miroir_task_id\n- [ ] Invalid `replica_group` (not present in current topology) → 400 with clear message\n- [ ] `POST /_miroir/rebalance` without prior topology change returns 200 and a no-op task (already balanced)\n- [ ] `GET .../rebalance/status` during a rebalance reflects per-shard state in near real time (< 5s staleness)","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:31:43.916640224Z","created_by":"coding","updated_at":"2026-04-24T03:52:38.353805421Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-4"],"dependencies":[{"issue_id":"miroir-mkk.6","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:38.353773025Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-mkk.6","depends_on_id":"miroir-mkk.2","type":"blocks","created_at":"2026-04-18T21:31:48.997646112Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-mkk.6","depends_on_id":"miroir-mkk.3","type":"blocks","created_at":"2026-04-18T21:31:49.023268953Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-mkk.6","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:38.337662255Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-n6v","title":"P12.OP4.1: Global-IDF preflight (dfs_query_then_fetch pattern)","description":"## What\n\nImplement global-IDF preflight query phase for Miroir to solve cross-shard score comparability (Plan §15 OP#4).\n\nResearch validation (bead miroir-zc2.4) confirmed:\n- Score-based merge: Kendall τ = 0.79 vs ground truth (FAIL, threshold 0.95)\n- RRF merge: Kendall τ = 0.14 vs ground truth (CATASTROPHIC)\n- Root cause: local IDF computed per-shard diverges from global IDF on skewed shard distributions\n\n## Approach\n\nElasticsearch `dfs_query_then_fetch` pattern:\n1. Preflight round: scatter term-frequency query to all shards\n2. Aggregate global document frequencies at coordinator\n3. Send global IDF with search query to shards\n4. Shards use global IDF for scoring instead of local\n\n## Acceptance\n\n- [ ] Preflight round implemented in scatter-gather pipeline\n- [ ] Global IDF aggregation at coordinator\n- [ ] Shards accept and use global IDF for scoring\n- [ ] Re-run benchmark: Kendall τ ≥ 0.95 with same skewed corpus\n- [ ] Latency overhead measured and documented\n\n## Reference\n\n- Research doc: docs/research/score-normalization-at-scale.md\n- Benchmark: tests/benches/score-comparability/\n- ES reference: https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-search-type.html#dfs-query-then-fetch","status":"closed","priority":2,"issue_type":"feature","assignee":"alpha","created_at":"2026-04-19T06:31:33.844052667Z","created_by":"coding","updated_at":"2026-04-19T12:46:10.248917395Z","closed_at":"2026-04-19T12:46:10.248705490Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","miroir","research","score-normalization"],"dependencies":[{"issue_id":"miroir-n6v","depends_on_id":"miroir-zc2.4","type":"related","created_at":"2026-04-19T06:32:11.786005093Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-nsu","title":"RRF Merging Implementation","description":"## Genesis Bead\nTied to plan: /home/coding/miroir/docs/plan/plan.md\n\n## Overview\nImplement Reciprocal Rank Fusion (RRF) for result merging in Miroir to address cross-shard score comparability issues identified in score-normalization-at-scale research.\n\n## Research Context\nExperiments (miroir-zc2.4) showed:\n- Average Kendall tau: 0.79 vs. 0.95 threshold (FAIL)\n- Common-term queries: τ = 0.15 (catastrophic)\n- RRF is the recommended solution (no preflight, production-proven)\n\n## Progress\n- [ ] Phase 1: Update Merger trait and stub\n- [ ] Phase 2: Implement RRF scoring\n- [ ] Phase 3: Benchmark against corpus\n- [ ] Phase 4: Integration with scatter-gather","status":"closed","priority":2,"issue_type":"genesis","assignee":"charlie","created_at":"2026-04-19T03:56:08.747340056Z","created_by":"coding","updated_at":"2026-04-19T06:24:21.290715173Z","closed_at":"2026-04-19T06:24:21.290611796Z","close_reason":"All four phases complete: MergeStrategy trait, RRF scoring (k=60), benchmarks re-run, scatter-gather integration. 26 merger + 15 scatter tests passing. Commits: 2b7f4a0, f5a630d, cec3b81","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1"]} -{"id":"miroir-ocl","title":"Create ArgoCD Application manifest","description":"## ArgoCD Application\n\n`k8s//miroir//` path in `jedarden/declarative-config`, automated sync + prune + selfHeal\n\nRegistry: `ghcr.io/jedarden/miroir`\nHelm chart OCI: `ghcr.io/jedarden/charts/miroir`\n\n## Acceptance\n- ArgoCD app syncs cleanly against ardenone-manager read-only proxy","status":"closed","priority":2,"issue_type":"task","assignee":"bravo","created_at":"2026-04-19T17:26:09.403236556Z","created_by":"coding","updated_at":"2026-04-19T18:37:07.085199067Z","closed_at":"2026-04-19T18:37:07.084555705Z","close_reason":"Added prod ArgoCD Application manifest (k8s/argocd/miroir-application.yaml) to the miroir repo, matching the manifest already in declarative-config. Both prod and dev manifests exist with automated sync + prune + selfHeal + ServerSideApply, using OCI Helm chart ghcr.io/jedarden/charts/miroir:0.1.0. Sync verification deferred — ardenone-manager proxy is down and Helm chart v0.1.0 not yet published to OCI registry.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","mitosis-child","mitosis-depth:1","parent-miroir-qjt"]} -{"id":"miroir-qjt","title":"Phase 8 — Deployment + CI (§6, §7)","description":"## Phase 8 Epic — Deployment + CI\n\nPackages Miroir: static musl binary → scratch Docker image → Helm chart → ArgoCD Application → Argo Workflows CI template (iad-ci). At phase end, `git tag v0.1.0 && git push origin v0.1.0` produces a signed GitHub Release with both `miroir-proxy` and `miroir-ctl`, a ghcr.io image, and a chart version bump.\n\n## Why This Phase (and Why It Depends On Phase 2)\n\nPlan §6 (Deployment) + §7 (CI/CD) turn the binary into a thing operators can actually install. Helm defaults (plan §6 \"Dev vs. production defaults\") encode the \"single-pod dev, multi-pod prod\" story from Phase 6. ArgoCD app + Argo Workflow template live in `jedarden/declarative-config` (see `/home/coding/CLAUDE.md`) — standard pattern across the fleet.\n\n## Scope\n\n**Dockerfile** (plan §7)\n- `FROM scratch` + static `miroir-proxy` binary\n- Expose 7700 + 9090\n- OCI labels: source, version, revision, licenses=MIT\n- Target size < 15 MB compressed\n\n**Cargo musl build** — `x86_64-unknown-linux-musl` target; `cargo build --release` for both `-p miroir-proxy` and `-p miroir-ctl`\n\n**Argo WorkflowTemplate `miroir-ci`** (plan §7) at `jedarden/declarative-config → k8s/iad-ci/argo-workflows/miroir-ci.yaml`\n- DAG: checkout → lint → test → build-binary → docker-build (tag-gated) → github-release (tag-gated)\n- `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test --all`, musl build\n- Kaniko for image push to `ghcr.io/jedarden/miroir:`, `:latest`, `:`, `:`\n- `gh release create` with both binaries + sha256\n\n**Helm chart `charts/miroir/`** (plan §6)\n- Templates: deployment, service, headless, configmap, secret, HPA, optional PVC (CDC), StatefulSet for meilisearch, meilisearch service, optional Redis deployment, serviceaccount\n- `values.yaml` with dev defaults (replicas=1, SQLite, RF=1, RG=1, HPA off)\n- `values.schema.json` that rejects:\n - `miroir.replicas > 1` with `taskStore.backend: sqlite`\n - `miroir.hpa.enabled: true` without `replicas >= 2 && taskStore.backend: redis`\n - `search_ui.rate_limit.backend: local` when `miroir.replicas > 1`\n - Admin login rate-limit local backend in HA\n - `search_ui.scoped_key_rotate_before_expiry_days >= scoped_key_max_age_days`\n- `_helpers.tpl` for fully-qualified StatefulSet DNS node addresses (plan §6 ConfigMap)\n- `NOTES.txt` with next-step pointers\n\n**ArgoCD Application** (plan §6) — `k8s//miroir//` path in `jedarden/declarative-config`, automated sync + prune + selfHeal\n\n**Release mechanics** (plan §7)\n- `CHANGELOG.md` Keep a Changelog format; CI extracts section for GitHub release notes\n- `Cargo.toml` workspace version bumped before tag\n- `Chart.yaml` `appVersion` bumped before tag\n- Tag format: `v[0-9]+.[0-9]+.[0-9]+*`\n\n## Infrastructure Reference\n\n- Registry: `ghcr.io/jedarden/miroir`\n- Helm chart OCI: `ghcr.io/jedarden/charts/miroir`\n- Pages: `https://jedarden.github.io/miroir`\n- CI secrets on iad-ci: `ghcr-credentials` (argo-workflows/.dockerconfigjson), `github-token` (argo-workflows/token)\n- Argo UI: `https://argo-ci.ardenone.com`\n\n## Definition of Done\n\n- [ ] `kubectl --kubeconfig=$HOME/.kube/iad-ci.kubeconfig apply -f workflow.yaml` completes the full CI pipeline on `main` within ~10 min\n- [ ] Pushing tag `v0.1.0-rc.1` produces a ghcr.io image, a GitHub pre-release, and does NOT update `latest`/float tags\n- [ ] `helm install search charts/miroir --namespace search --wait` stands up a working single-pod cluster\n- [ ] `values.schema.json` rejections tested via `helm lint --strict` with mutating values files\n- [ ] Final image ≤ 15 MB compressed\n- [ ] ArgoCD app syncs cleanly against ardenone-manager read-only proxy","status":"closed","priority":0,"issue_type":"epic","assignee":"bravo","created_at":"2026-04-18T21:21:13.608558775Z","created_by":"coding","updated_at":"2026-04-19T19:09:44.428595970Z","closed_at":"2026-04-19T19:09:44.428314256Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:4","phase","phase-8"],"dependencies":[{"issue_id":"miroir-qjt","depends_on_id":"miroir-15j","type":"blocks","created_at":"2026-04-19T17:26:09.372736465Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-qjt","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-18T21:23:08.690406249Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-qjt","depends_on_id":"miroir-exo","type":"blocks","created_at":"2026-04-19T17:26:09.224465412Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-qjt","depends_on_id":"miroir-g7i","type":"blocks","created_at":"2026-04-19T17:26:09.299720616Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-qjt","depends_on_id":"miroir-ocl","type":"blocks","created_at":"2026-04-19T17:26:09.429179779Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-qjt.1","title":"P8.1 Dockerfile: scratch + static musl miroir-proxy","description":"## What\n\nShip the `Dockerfile` from plan §7:\n```dockerfile\nFROM scratch\nCOPY miroir-proxy-linux-amd64 /miroir-proxy\nEXPOSE 7700 9090\nENTRYPOINT [\"/miroir-proxy\"]\nCMD [\"--config\", \"/etc/miroir/config.yaml\"]\n```\n\nOCI labels (plan §12):\n```\norg.opencontainers.image.source=https://github.com/jedarden/miroir\norg.opencontainers.image.version=\norg.opencontainers.image.revision=\norg.opencontainers.image.licenses=MIT\n```\n\nTarget: compressed image < 15 MB.\n\n## Why\n\nPlan §1 principle 6 + §12: \"scratch base, no libc. Zero OS packages, no shell.\" This is the smallest possible attack surface and the fastest possible pull (one layer, tiny). Makes trivial deploys feasible on edge clusters.\n\n## Details\n\n**Musl build step** (plan §7 `cargo-build` template):\n```bash\napt-get install -qy musl-tools\nrustup target add x86_64-unknown-linux-musl\ncargo build --release --target x86_64-unknown-linux-musl -p miroir-proxy\ncargo build --release --target x86_64-unknown-linux-musl -p miroir-ctl\nsha256sum miroir-proxy-linux-amd64 > miroir-proxy-linux-amd64.sha256\n```\n\n**Layers**: COPY the static binary directly from `/workspace/artifacts/` into `/miroir-proxy` in the scratch image.\n\n**Config mount**: `/etc/miroir/config.yaml` via ConfigMap mount (Helm chart).\n\n**No shell = no `docker exec -it` debugging** — intentional. Debug by logs + metrics + `kubectl describe` only. Operators who need shell can run a sidecar.\n\n## Acceptance\n\n- [ ] `docker build .` on an artifact-equipped workspace produces an image < 15 MB compressed\n- [ ] `docker run --help` returns clap help (binary works from scratch base)\n- [ ] Image labels contain all 4 OCI labels with correct values\n- [ ] Static linkage: `ldd` against the extracted binary prints \"not a dynamic executable\"","status":"closed","priority":0,"issue_type":"task","assignee":"delta","created_at":"2026-04-18T21:43:56.826575101Z","created_by":"coding","updated_at":"2026-04-19T13:45:10.855467225Z","closed_at":"2026-04-19T13:45:10.855361026Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2","phase-8"],"dependencies":[{"issue_id":"miroir-qjt.1","depends_on_id":"miroir-qjt","type":"parent-child","created_at":"2026-04-18T21:43:56.826575101Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-qjt.2","title":"P8.2 Helm chart structure + values.yaml dev defaults","description":"## What\n\nScaffold `charts/miroir/` per plan §6:\n```\ncharts/miroir/\n├── Chart.yaml\n├── values.yaml\n├── values.schema.json\n├── templates/\n│ ├── _helpers.tpl\n│ ├── miroir-deployment.yaml\n│ ├── miroir-service.yaml\n│ ├── miroir-headless.yaml\n│ ├── miroir-configmap.yaml\n│ ├── miroir-secret.yaml\n│ ├── miroir-hpa.yaml\n│ ├── miroir-pvc.yaml (optional; rendered only when cdc.buffer.primary=pvc or overflow=pvc)\n│ ├── meilisearch-statefulset.yaml\n│ ├── meilisearch-service.yaml\n│ ├── redis-deployment.yaml (when taskStore.backend=redis)\n│ ├── serviceaccount.yaml\n│ └── NOTES.txt\n└── tests/connection-test.yaml\n```\n\n**values.yaml dev defaults** (plan §6 \"Dev vs. production defaults\"):\n- `miroir.replicas: 1`\n- `miroir.shards: 64`\n- `miroir.replicationFactor: 1`\n- `miroir.replicaGroups: 1`\n- `miroir.hpa.enabled: false`\n- `meilisearch.replicas: 2` (1 group × 2 nodes)\n- `meilisearch.nodesPerGroup: 2`\n- `redis.enabled: false`\n- `taskStore.backend: sqlite`\n\n**Production override guidance**: callout in NOTES.txt pointing at the prod-override values (replicas=2+, RF=2, RG=2, redis+hpa both on).\n\n## Why\n\nPlan §6: \"These defaults boot a working single-pod install for evaluation and CI. For production, override to...\" Clear dev/prod split so a new user can `helm install` and get *something working*, while a production user has a clear upgrade path.\n\n## Details\n\n**Chart.yaml**:\n```yaml\napiVersion: v2\nname: miroir\nversion: 0.1.0\nappVersion: 0.1.0\ndescription: RAID-like sharding and HA for Meilisearch Community Edition\nkeywords: [search, meilisearch, sharding, kubernetes]\nhome: https://github.com/jedarden/miroir\nsources: [https://github.com/jedarden/miroir]\n```\n\n**`_helpers.tpl`** — generates the node list DNS (plan §6 ConfigMap): `http://-meili-.-meili-headless..svc.cluster.local:7700`.\n\n**Chart testing**: `charts/miroir/tests/` with `helm-testing` pod that runs `curl localhost:7700/health`.\n\n## Acceptance\n\n- [ ] `helm lint charts/miroir` passes\n- [ ] `helm install test charts/miroir --dry-run --debug` renders all templates without error\n- [ ] `helm install test charts/miroir --wait` stands up a working single-pod cluster with defaults\n- [ ] `helm test test` passes (the connection test pod curl-succeeds on /health)","status":"closed","priority":0,"issue_type":"task","assignee":"alpha","created_at":"2026-04-18T21:43:56.872715171Z","created_by":"coding","updated_at":"2026-04-19T16:36:31.011938242Z","closed_at":"2026-04-19T16:36:31.011770277Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:100","phase-8"],"dependencies":[{"issue_id":"miroir-qjt.2","depends_on_id":"miroir-qjt","type":"parent-child","created_at":"2026-04-18T21:43:56.872715171Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-qjt.2","depends_on_id":"miroir-qjt.1","type":"blocks","created_at":"2026-04-18T21:44:01.416733808Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-qjt.3","title":"P8.3 values.schema.json rejections for incompatible configs","description":"## What\n\nImplement the `values.schema.json` constraints called out across the plan:\n\n1. **`miroir.replicas > 1` requires `taskStore.backend: redis`** (plan §6, §14.4)\n2. **`hpa.enabled: true` requires `replicas >= 2 AND taskStore.backend: redis`** (plan §14.4)\n3. **`search_ui.rate_limit.backend: local` rejected when `miroir.replicas > 1`** (plan §13.21 + §14.6)\n4. **Admin login rate-limit `backend: local` rejected when `miroir.replicas > 1`** (plan §4 `admin_sessions` / §13.19)\n5. **`search_ui.scoped_key_rotate_before_expiry_days >= scoped_key_max_age_days`** (plan §13.21 \"Config validation\")\n6. Any other \"Helm schema rejects...\" callouts found across the plan\n\n## Why\n\nPlan §13.21 Config validation paragraph is explicit: \"such a configuration would cause rotation to fire immediately (or before) key issuance, producing a continuous rotation loop.\" These schema checks catch class-of-error misconfigurations at `helm install` time rather than at 3 AM.\n\n## Details\n\nUse JSON Schema `if/then` and `not`:\n```jsonc\n{\n \"$id\": \"https://github.com/jedarden/miroir/charts/miroir/values.schema.json\",\n \"type\": \"object\",\n \"properties\": {\n \"miroir\": { ... },\n \"taskStore\": { ... },\n \"search_ui\": { ... }\n },\n \"allOf\": [\n { \"if\": {...replicas>1...}, \"then\": {...backend==redis...} },\n { \"if\": {...hpa.enabled...}, \"then\": {...replicas>=2 AND backend==redis...} },\n {\n \"if\": {...replicas>1...},\n \"then\": {...search_ui.rate_limit.backend !== \"local\"...}\n },\n {\n \"properties\": {\n \"search_ui\": {\n \"properties\": {\n \"scoped_key_rotate_before_expiry_days\": {\"type\": \"integer\", \"minimum\": 1},\n \"scoped_key_max_age_days\": {\"type\": \"integer\", \"minimum\": 2}\n },\n \"allOf\": [\n {\n \"not\": {\n \"properties\": {\n \"scoped_key_rotate_before_expiry_days\": {...},\n \"scoped_key_max_age_days\": {...}\n }\n }\n }\n ]\n }\n }\n }\n ]\n}\n```\n\n**Test cases** (in `charts/miroir/tests/`):\n- Each constraint has a `bad-values.yaml` that must fail `helm lint --strict`\n- A `good-values.yaml` that must pass\n\n**Error messages**: use `errorMessage` extension where operator-readable matters (e.g., \"SQLite task store cannot run with multiple replicas; set taskStore.backend=redis\").\n\n## Acceptance\n\n- [ ] 5+ bad-values.yaml files all fail `helm lint --strict` with clear messages\n- [ ] good-values.yaml combinations pass\n- [ ] Phase 9 CI includes the schema rejection tests","status":"closed","priority":0,"issue_type":"task","assignee":"alpha","created_at":"2026-04-18T21:43:56.911681441Z","created_by":"coding","updated_at":"2026-04-19T17:14:43.601415365Z","closed_at":"2026-04-19T17:14:43.601350096Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:3","phase-8"],"dependencies":[{"issue_id":"miroir-qjt.3","depends_on_id":"miroir-qjt","type":"parent-child","created_at":"2026-04-18T21:43:56.911681441Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-qjt.3","depends_on_id":"miroir-qjt.2","type":"blocks","created_at":"2026-04-18T21:44:01.441452049Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-qjt.4","title":"P8.4 Argo Workflows CI template: miroir-ci.yaml","description":"## What\n\nShip the plan §7 Argo Workflow template at `jedarden/declarative-config → k8s/iad-ci/argo-workflows/miroir-ci.yaml`, synced by ArgoCD app `argo-workflows-ns-iad-ci`.\n\n**Pipeline DAG**:\n```\ncheckout → [lint, test] → build-binary → [docker-build, github-release] (tag-gated)\n```\n\n**Steps** (each a separate WorkflowTemplate entry):\n- `git-checkout` — `alpine/git:2.43.0` → clones to `/workspace/src`\n- `cargo-lint` — `rust:1.87-slim` → `cargo fmt --check && cargo clippy -D warnings`\n- `cargo-test` — `rust:1.87-slim` → `cargo test --all --all-features` (2 CPU, 4 GiB)\n- `cargo-build` — `rust:1.87-slim` + `musl-tools` → `cargo build --release --target x86_64-unknown-linux-musl` for `miroir-proxy` and `miroir-ctl` (4 CPU, 8 GiB); sha256 sums emitted\n- `docker-build-push` — `gcr.io/kaniko-project/executor:v1.23.0` → push to `ghcr.io/jedarden/miroir:{tag,latest}` with cache (tag-gated)\n- `create-github-release` — `ghcr.io/cli/cli:2.49.0` → extracts notes from CHANGELOG.md using plan §7 awk script; uploads both binaries + sha256s\n\n## Why\n\nInfrastructure conventions: declarative-config is the source-of-truth for all Argo WorkflowTemplates across the fleet. Putting miroir-ci.yaml there means the pipeline is deployable via `kubectl apply` on the iad-ci cluster once declarative-config syncs.\n\n## Details\n\n**Volume**: `ReadWriteOnce` 8 GiB claim template shared across pipeline steps.\n\n**Parameters**: `repo` (default `https://github.com/jedarden/miroir.git`), `revision` (default `main`), `tag` (default empty; when set triggers release steps).\n\n**Image tagging** (plan §7):\n- `v0.3.2` → `ghcr.io/jedarden/miroir:v0.3.2` + `:0.3` + `:0` + `:latest`\n- `v0.3.2-rc.1` → only `:v0.3.2-rc.1`, no float tags, no `:latest`\n- `main-` for non-tagged branch builds\n\n**Secrets on iad-ci** (plan §7):\n- `ghcr-credentials` in `argo-workflows` namespace, key `.dockerconfigjson`\n- `github-token` in `argo-workflows` namespace, key `token`\n\n## Acceptance\n\n- [ ] Template lives at `k8s/iad-ci/argo-workflows/miroir-ci.yaml` and is synced by ArgoCD\n- [ ] Manual submit: `kubectl --kubeconfig=$HOME/.kube/iad-ci.kubeconfig create -f ...` runs the full pipeline on `main` in ~10 min\n- [ ] Release tag build: `tag=v0.1.0` produces all 4 ghcr image tags + a GitHub release with 4 asset files\n- [ ] Pre-release tag: `v0.1.0-rc.1` does NOT push `:latest` or float tags","status":"closed","priority":0,"issue_type":"task","assignee":"as-20260419011605-0","created_at":"2026-04-18T21:43:56.949848643Z","created_by":"coding","updated_at":"2026-04-19T13:50:01.440145739Z","closed_at":"2026-04-19T13:50:01.439950464Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-8"],"dependencies":[{"issue_id":"miroir-qjt.4","depends_on_id":"miroir-qjt","type":"parent-child","created_at":"2026-04-18T21:43:56.949848643Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-qjt.4","depends_on_id":"miroir-qjt.1","type":"blocks","created_at":"2026-04-18T21:44:01.468146617Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-qjt.5","title":"P8.5 ArgoCD Application manifest","description":"## What\n\nShip per-instance ArgoCD `Application` manifests in `jedarden/declarative-config → k8s//miroir//` (plan §6):\n```yaml\napiVersion: argoproj.io/v1alpha1\nkind: Application\nmetadata:\n name: miroir-\n namespace: argocd\nspec:\n project: default\n source:\n repoURL: https://github.com/jedarden/declarative-config\n targetRevision: HEAD\n path: k8s//miroir/\n helm:\n valueFiles: [values.yaml]\n destination:\n server: https://kubernetes.default.svc\n namespace: \n syncPolicy:\n automated: { prune: true, selfHeal: true }\n syncOptions: [CreateNamespace=true, ServerSideApply=true]\n```\n\nEach instance folder holds:\n- `values.yaml` — instance-specific Helm values (which cluster, namespace, ingress host, secrets refs)\n- `Chart.yaml` — a shim referencing the upstream chart via OCI or git\n\n## Why\n\nPer-cluster CLAUDE.md convention: ArgoCD drives all cluster changes. Plan §1 principle 7: \"GitOps first — all deployment configuration committed to `jedarden/declarative-config`; ArgoCD drives all cluster changes.\" No out-of-band kubectl applies.\n\n## Details\n\n**Multi-cluster**: dirs per cluster (`apexalgo-iad`, `ardenone-cluster`, `ardenone-manager`, `rs-manager`) — each hosts zero or more Miroir instances.\n\n**Chart sourcing**: options are\n1. Git submodule (pin to miroir repo SHA)\n2. OCI: `ghcr.io/jedarden/charts/miroir:`\n3. Helm repo: `https://jedarden.github.io/miroir`\n\nDefault to (2) since it pins by digest.\n\n**SelfHeal + prune**: standard fleet pattern (plan §6 syncPolicy). Matches other apps on ardenone-manager.\n\n**ESO ExternalSecret** (plan §6 ESO section): co-located in the instance dir so secrets + app ship together.\n\n## Acceptance\n\n- [ ] `kubectl --kubeconfig=$HOME/.kube/ardenone-manager.kubeconfig apply -f app.yaml` creates the Application\n- [ ] ArgoCD sync produces a healthy deployment on the target cluster\n- [ ] SelfHeal: manually delete the Miroir Deployment → ArgoCD recreates within minutes\n- [ ] Prune: remove a template from the chart → ArgoCD deletes the orphaned resource","status":"closed","priority":0,"issue_type":"task","assignee":"charlie","created_at":"2026-04-18T21:43:56.999215165Z","created_by":"coding","updated_at":"2026-04-19T16:45:24.936172790Z","closed_at":"2026-04-19T16:45:24.935968095Z","close_reason":"P8.5: Shipped ArgoCD Application manifest for miroir-dev instance.\n\nCreated in jedarden/declarative-config (k8s/ardenone-cluster/miroir-dev/):\n- namespace.yml, miroir-dev-application.yml (OCI chart, inline values), miroir-dev-externalsecret.yml, miroir-dev-secret.yml.template\n\nDev config: 1 miroir replica, sqlite, 2 meilisearch nodes, ServiceMonitor+PrometheusRule enabled, selfHeal+prune.\n\nCommitted and pushed to declarative-config main (703f728).","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-8"],"dependencies":[{"issue_id":"miroir-qjt.5","depends_on_id":"miroir-qjt","type":"parent-child","created_at":"2026-04-18T21:43:56.999215165Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-qjt.5","depends_on_id":"miroir-qjt.2","type":"blocks","created_at":"2026-04-18T21:44:01.493398218Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-qjt.6","title":"P8.6 Release mechanics: CHANGELOG parser, version bumps, tag triggers","description":"## What\n\nWire the full release mechanics per plan §7:\n\n- **CHANGELOG extraction** via the plan §7 awk script:\n ```\n NOTES=$(awk \"/^## \\[${TAG#v}\\]/{found=1; next} found && /^## /{exit} found{print}\" CHANGELOG.md)\n ```\n- **Cargo.toml version sync**: workspace version + Chart.yaml appVersion must both bump before tagging\n- **Tag format**: `v[0-9]+.[0-9]+.[0-9]+*` triggers CI — including pre-release suffixes (`-rc.1`, `-alpha.2`)\n- **Pre-release handling**: no `:latest` or float tags for pre-releases\n- **Release checklist in the repo** (plan §7):\n - [ ] All tests pass on `main`\n - [ ] `CHANGELOG.md` updated with new version section\n - [ ] `Cargo.toml` workspace version bumped\n - [ ] `Chart.yaml` `appVersion` updated\n - [ ] Migration notes written if task store schema changed\n\n## Why\n\nPlan §12 commits to SemVer with backward-compat promises from v1.0. Unstructured release processes make those promises impossible to keep. Automation of version sync + release notes prevents the \"we forgot to update Chart.yaml\" class of error.\n\n## Details\n\n**Version-bump script** (`scripts/bump-version.sh`):\n```bash\n#!/bin/bash\nNEW_VERSION=$1\nsed -i \"s/^version = .*/version = \\\"$NEW_VERSION\\\"/\" Cargo.toml\nsed -i \"s/^version: .*/version: $NEW_VERSION/\" charts/miroir/Chart.yaml\nsed -i \"s/^appVersion: .*/appVersion: $NEW_VERSION/\" charts/miroir/Chart.yaml\n```\n\n**Release PR template**: every release PR includes the checklist from plan §7 and a diff of CHANGELOG.md.\n\n**CI enforcement**: a `release-ready` CI step verifies Cargo workspace version, Chart.yaml appVersion, and the CHANGELOG header all agree on the tag. Runs on every PR that modifies any of those files.\n\n**Chart repo publication** (plan §12):\n- `https://jedarden.github.io/miroir` (gh-pages branch with index.yaml)\n- `ghcr.io/jedarden/charts/miroir` (OCI push from Argo Workflow)\n\n## Acceptance\n\n- [ ] `scripts/bump-version.sh 0.2.0` updates all 3 files atomically\n- [ ] Tagging `v0.2.0` fires the CI release path and produces: GitHub release, ghcr image with 4 tags (`v0.2.0, 0.2, 0, latest`), chart published to gh-pages + OCI\n- [ ] Tagging `v0.2.0-rc.1` produces only the exact tag; no `latest`/float tags\n- [ ] `release-ready` check fails a PR that bumps Cargo but not Chart.yaml","status":"closed","priority":1,"issue_type":"task","assignee":"as-20260419011605-0","created_at":"2026-04-18T21:43:57.027884427Z","created_by":"coding","updated_at":"2026-04-19T13:54:37.349127396Z","closed_at":"2026-04-19T13:54:37.348980159Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-8"],"dependencies":[{"issue_id":"miroir-qjt.6","depends_on_id":"miroir-qjt","type":"parent-child","created_at":"2026-04-18T21:43:57.027884427Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-qjt.6","depends_on_id":"miroir-qjt.4","type":"blocks","created_at":"2026-04-18T21:44:01.524106188Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-qjt.7","title":"P8.7 Helm values for CDC PVC, Redis, ESO integration","description":"## What\n\nConditional Helm templates + values for optional capabilities:\n\n1. **`miroir-pvc.yaml`** rendered only when `cdc.buffer.primary == \"pvc\"` OR `cdc.buffer.overflow == \"pvc\"` (plan §13.13). Mounts at `/data/cdc`.\n2. **`redis-deployment.yaml`** rendered when `redis.enabled: true`. Simple single-replica Redis for dev; production operators point `taskStore.url` at a managed Redis.\n3. **ESO `ExternalSecret`** example in `examples/eso-external-secret.yaml` (plan §6 ESO section). Pulls from `kv/search/miroir` in OpenBao via `openbao-backend` ClusterSecretStore.\n\n## Why\n\nPlan §13.13: \"Miroir runs from a `scratch` container image with no writable filesystem by default.\" Without the optional PVC template, operators who enable `cdc.buffer.overflow: pvc` get a silent NPE. Making the template conditional on the config value keeps the non-CDC chart tidy.\n\nPlan §9 ESO integration: pulling secrets from OpenBao (rather than baking into values.yaml) is the standard fleet pattern.\n\n## Details\n\n**PVC template**:\n```yaml\n{{- if or (eq .Values.cdc.buffer.primary \"pvc\") (eq .Values.cdc.buffer.overflow \"pvc\") }}\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n name: {{ include \"miroir.fullname\" . }}-cdc\nspec:\n accessModes: [ReadWriteOnce]\n resources:\n requests:\n storage: {{ .Values.cdc.buffer.pvc_size | default \"10Gi\" }}\n{{- end }}\n```\n\n**Redis values** (chart defaults):\n```yaml\nredis:\n enabled: false\n image: redis:7.4-alpine\n persistence:\n enabled: true\n size: 5Gi\n auth:\n enabled: true\n # password comes from K8s Secret `miroir-redis-secrets` / ESO\n```\n\n**ESO example** (plan §6):\n```yaml\napiVersion: external-secrets.io/v1beta1\nkind: ExternalSecret\nmetadata:\n name: miroir-secrets\nspec:\n refreshInterval: 1h\n secretStoreRef:\n name: openbao-backend\n kind: ClusterSecretStore\n target:\n name: miroir-secrets\n creationPolicy: Owner\n data:\n - secretKey: masterKey\n remoteRef: { key: kv/search/miroir, property: master_key }\n - secretKey: nodeMasterKey\n remoteRef: { key: kv/search/miroir, property: node_master_key }\n - secretKey: adminApiKey\n remoteRef: { key: kv/search/miroir, property: admin_api_key }\n```\n\n## Acceptance\n\n- [ ] With `cdc.buffer.overflow: pvc` → PVC manifest rendered; helm install mounts at /data/cdc\n- [ ] With default values → no PVC manifest rendered\n- [ ] `redis.enabled: true` → redis-deployment.yaml + service rendered; Miroir ConfigMap points `taskStore.url` at it\n- [ ] ESO example deploys cleanly against ardenone-cluster's OpenBao (once v0.x is published)","status":"closed","priority":2,"issue_type":"task","assignee":"charlie","created_at":"2026-04-18T21:43:57.059546985Z","created_by":"coding","updated_at":"2026-04-19T17:16:45.194831233Z","closed_at":"2026-04-19T17:16:45.194515502Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:3","phase-8"],"dependencies":[{"issue_id":"miroir-qjt.7","depends_on_id":"miroir-qjt","type":"parent-child","created_at":"2026-04-18T21:43:57.059546985Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-qjt.7","depends_on_id":"miroir-qjt.2","type":"blocks","created_at":"2026-04-18T21:44:01.551672128Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-qon","title":"Phase 0 — Foundation (workspace, crates, config, deps)","description":"## Phase 0 Epic — Foundation\n\nEstablishes the Rust project scaffolding that every subsequent phase builds on. When this phase is done, the repo has a compilable (but non-functional) Cargo workspace with the three crates specified in plan §4 and a fully-typed config struct representing plan §4's YAML schema.\n\n## Why This Phase First\n\nEvery later phase assumes:\n- The crate layout `miroir-core / miroir-proxy / miroir-ctl` exists\n- The `Config` struct and its `validate()` routine can be imported\n- The workspace compiles under a stable Rust toolchain pinned in `rust-toolchain.toml`\n- `cargo test --all` exists and runs (even if empty)\n- CI (Phase 8) targets the same layout\n\nSkipping this phase or deferring \"boring\" bits (deps, lints, musl target) causes expensive backtracking once higher-level work is in flight.\n\n## Scope (plan §4 — Implementation)\n\n- Cargo workspace at repo root\n- `crates/miroir-core` library (routing, merging, topology primitives)\n- `crates/miroir-proxy` HTTP binary (axum server skeleton)\n- `crates/miroir-ctl` CLI binary (clap subcommand skeleton)\n- `rust-toolchain.toml` pinning a stable version compatible with Rust 1.87+ (per CI workflow)\n- Key deps wired: axum, tokio (multi-threaded), reqwest, twox-hash, serde, serde_json, config, rusqlite, prometheus, tracing + tracing-subscriber, clap, uuid\n- `Config` struct mirroring the full YAML schema in plan §4 (even empty defaults for features not yet built)\n- `rustfmt.toml` + `clippy.toml` + `.editorconfig` so style is consistent from commit 1\n- `Cargo.lock` committed (binary crate)\n- `CHANGELOG.md` scaffold (Keep a Changelog format — CI release step extracts sections from this)\n- `LICENSE` (MIT, per §12)\n- `.gitignore`\n\n## Out of Scope\n\n- Actual routing logic (Phase 1)\n- Proxy handlers beyond a `/health` stub (Phase 2)\n- Task registry schema (Phase 3)\n- Anything in §13 (Phase 5)\n\n## Definition of Done\n\n- [ ] `cargo build --all` succeeds\n- [ ] `cargo test --all` succeeds (even with zero tests)\n- [ ] `cargo clippy --all-targets --all-features -- -D warnings` passes\n- [ ] `cargo fmt --all -- --check` passes\n- [ ] `cargo build --release --target x86_64-unknown-linux-musl -p miroir-proxy` succeeds\n- [ ] `Config` round-trips YAML → struct → YAML and matches plan §4 shape\n- [ ] All child beads for this phase are closed","status":"closed","priority":0,"issue_type":"epic","assignee":"charlie","created_at":"2026-04-18T21:18:33.116054928Z","created_by":"coding","updated_at":"2026-04-19T03:39:13.845854547Z","closed_at":"2026-04-19T03:39:13.845788661Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","phase","phase-0"]} -{"id":"miroir-qon.1","title":"P0.1 Set up Cargo workspace + toolchain pin","description":"## What\n\nCreate the root Cargo workspace (`Cargo.toml` with `[workspace]` members), pin the Rust toolchain (`rust-toolchain.toml`), and add lint config (`rustfmt.toml`, `clippy.toml`, `.editorconfig`).\n\n## Why\n\nEverything else compiles against this. A pinned toolchain prevents \"works on my machine\" drift across contributors + CI (`rust:1.87-slim` per plan §7). Lint config in the repo from day 1 means we never have to retrofit formatting.\n\n## Details\n\n**Cargo.toml (workspace root):**\n```toml\n[workspace]\nresolver = \"2\"\nmembers = [\"crates/miroir-core\", \"crates/miroir-proxy\", \"crates/miroir-ctl\"]\n\n[workspace.package]\nversion = \"0.1.0\"\nedition = \"2021\"\nlicense = \"MIT\"\nrepository = \"https://github.com/jedarden/miroir\"\nrust-version = \"1.87\"\n```\n\n**rust-toolchain.toml:**\n```toml\n[toolchain]\nchannel = \"1.87\"\ncomponents = [\"rustfmt\", \"clippy\"]\ntargets = [\"x86_64-unknown-linux-musl\"]\n```\n\n**rustfmt.toml:** conservative default; `max_width = 100`, `edition = \"2021\"`.\n\n**clippy.toml:** empty for now; the `-D warnings` enforcement lives in CI (plan §7 `cargo-lint` template).\n\n## Acceptance\n\n- [ ] `cargo build` succeeds on an empty workspace (no members are complete yet but the workspace file parses)\n- [ ] `rustup show` in CI confirms the pinned channel\n- [ ] `cargo fmt --all -- --check` is a no-op (no files to check yet)\n- [ ] `cargo clippy --all-targets -- -D warnings` is a no-op","status":"closed","priority":0,"issue_type":"task","assignee":"alpha","created_at":"2026-04-18T21:24:25.694504043Z","created_by":"coding","updated_at":"2026-04-19T00:53:19.797128870Z","closed_at":"2026-04-19T00:53:19.796677927Z","close_reason":"Updated workspace Cargo.toml to match spec: explicit members list (miroir-core, miroir-proxy, miroir-ctl), edition 2021, MIT license, rust-version 1.87. Existing rust-toolchain.toml, rustfmt.toml, clippy.toml, and .editorconfig already matched requirements. Workspace metadata parses correctly with all three members identified.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","phase-0"],"dependencies":[{"issue_id":"miroir-qon.1","depends_on_id":"miroir-qon","type":"parent-child","created_at":"2026-04-18T21:24:25.694504043Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-qon.2","title":"P0.2 Scaffold miroir-core crate","description":"## What\n\nCreate `crates/miroir-core/` with the module skeleton from plan §4:\n- `src/lib.rs` — public re-exports\n- `src/router.rs` — rendezvous hash primitives (signatures only; implementation in Phase 1)\n- `src/topology.rs` — `Topology`, `Group`, `Node`, `NodeId`, `NodeStatus` types\n- `src/scatter.rs` — scatter orchestration trait/stubs\n- `src/merger.rs` — result merge trait/stubs\n- `src/task.rs` — task registry trait/stubs\n- `src/config.rs` — `Config` struct (full shape matching plan §4 YAML)\n- `src/error.rs` — `MiroirError` enum + `Result` alias\n\n## Why\n\nThe module boundary is intentional: pure library vs. binaries. `miroir-core` must stay dependency-light (no HTTP server, no CLI crate) so both binaries and downstream users can depend on it cleanly. This is also where the coverage gate (≥ 90%) applies per plan §8 coverage policy.\n\n## Details\n\n- Crate-type: `lib` (default); no `[[bin]]`\n- `Cargo.toml` deps: `serde`, `serde_json`, `twox-hash`, `thiserror`, `tracing` (minimal set — concrete feature-specific deps added as they're needed)\n- Public API starts small — add `pub use` entries to `lib.rs` only as modules are completed\n\n## Acceptance\n\n- [ ] `cargo build -p miroir-core` succeeds with empty stubs\n- [ ] `cargo doc -p miroir-core` produces rustdoc without warnings\n- [ ] `cargo test -p miroir-core` runs (zero tests) successfully","status":"closed","priority":0,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:24:25.717048243Z","created_by":"coding","updated_at":"2026-04-19T00:57:59.769651734Z","closed_at":"2026-04-19T00:57:59.769335750Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2","phase-0"],"dependencies":[{"issue_id":"miroir-qon.2","depends_on_id":"miroir-qon","type":"parent-child","created_at":"2026-04-18T21:24:25.717048243Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-qon.3","title":"P0.3 Scaffold miroir-proxy crate","description":"## What\n\nCreate `crates/miroir-proxy/` — the HTTP proxy binary. Module layout from plan §4:\n- `src/main.rs` — startup (load config, init logging, start axum server, install signal handlers)\n- `src/routes/documents.rs`, `search.rs`, `indexes.rs`, `settings.rs`, `tasks.rs`, `health.rs`, `admin.rs` — route handler stubs\n- `src/auth.rs` — bearer-token dispatch per plan §5 (stubbed; real logic in Phase 2)\n- `src/middleware.rs` — tracing/logging + Prometheus middleware stubs\n\n## Why\n\nThis is the thing users install. Separating route modules by concern makes the bearer-token dispatch (plan §5 rules 0–5) and admin-vs-client path split (plan §4 admin API table) obvious from the source tree.\n\n## Details\n\n- `Cargo.toml` deps: `axum`, `tokio` (multi-thread), `reqwest`, `serde`, `serde_json`, `config` (the crate), `tracing`, `tracing-subscriber`, `prometheus`, `miroir-core` (path dep)\n- `main.rs` should already bind `:7700` for the main server and `:9090` for metrics, even if every route returns `501 Not Implemented`\n- Stub `GET /health` to return `{\"status\":\"available\"}` (Meilisearch-compatible; used as K8s liveness)\n\n## Acceptance\n\n- [ ] `cargo build -p miroir-proxy --release --target x86_64-unknown-linux-musl` succeeds\n- [ ] Running the binary binds :7700 and :9090 and `curl http://localhost:7700/health` returns 200\n- [ ] Binary size (release, stripped) < 20 MB — ensures we hit the \"< 15 MB compressed\" target after Docker layer compression","status":"closed","priority":0,"issue_type":"task","assignee":"charlie","created_at":"2026-04-18T21:24:25.730677032Z","created_by":"coding","updated_at":"2026-04-19T00:58:12.176910752Z","closed_at":"2026-04-19T00:58:12.176602790Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2","phase-0"],"dependencies":[{"issue_id":"miroir-qon.3","depends_on_id":"miroir-qon","type":"parent-child","created_at":"2026-04-18T21:24:25.730677032Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-qon.4","title":"P0.4 Scaffold miroir-ctl crate","description":"## What\n\nCreate `crates/miroir-ctl/` — the management CLI. Module layout from plan §4:\n- `src/main.rs` — clap root, credential loading (plan §9 priority order)\n- `src/commands/{status,node,rebalance,reshard,verify,task,dump,alias,canary,ttl,cdc,shadow,ui,tenant,explain}.rs` — subcommand stubs\n\n## Why\n\nPlan §11 onboarding shows `miroir-ctl status`, `node add`, `rebalance status --watch`, `task status`, etc. These need to exist from early on so Phase 2+ features write their CLI as they go rather than accumulating a todo list. Also the admin-key loading priority (env → `~/.config/miroir/credentials` → `--admin-key` flag) deserves its own unit-testable module from day 1.\n\n## Details\n\n- `Cargo.toml` deps: `clap` (derive), `reqwest`, `serde`, `serde_json`, `tokio`, `miroir-core`\n- Admin-key loading order per plan §9 `miroir-ctl credential handling`:\n 1. `MIROIR_ADMIN_API_KEY` env\n 2. `~/.config/miroir/credentials` TOML\n 3. `--admin-key` flag (warn about process-list visibility in the help text)\n- Every subcommand returns `Err(\"not yet implemented\")` with a clear \"tracked in bead miroir-*\" message for now\n\n## Acceptance\n\n- [ ] `cargo build -p miroir-ctl --release --target x86_64-unknown-linux-musl` succeeds\n- [ ] `miroir-ctl --help` lists every subcommand enumerated in plan §4\n- [ ] Admin-key loader has a unit test for each of the 3 priority paths","status":"closed","priority":0,"issue_type":"task","assignee":"delta","created_at":"2026-04-18T21:24:25.751005786Z","created_by":"coding","updated_at":"2026-04-19T01:01:36.003063126Z","closed_at":"2026-04-19T01:01:36.002955040Z","close_reason":"Scaffolded miroir-ctl crate with all 15 subcommands from plan §4:\n\n- clap root CLI with credential loading (priority: MIROIR_ADMIN_API_KEY env → ~/.config/miroir/credentials.toml → --admin-key flag)\n- All subcommand stubs: status, node, rebalance, reshard, verify, task, dump, alias, canary, ttl, cdc, shadow, ui, tenant, explain\n- Unit tests for credential loader covering all 3 priority paths\n- Each subcommand returns clear 'not yet implemented' message pointing to tracking bead\n\nNote: musl target build not tested due to missing x86_64-linux-musl-gcc toolchain on this system; native release build succeeds.","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:3","phase-0"],"dependencies":[{"issue_id":"miroir-qon.4","depends_on_id":"miroir-qon","type":"parent-child","created_at":"2026-04-18T21:24:25.751005786Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-qon.5","title":"P0.5 Config struct mirroring plan §4 YAML schema","description":"## What\n\nImplement `miroir_core::config::Config` — a `serde`-derived struct tree matching the plan §4 YAML schema exactly, including the §13 advanced-capabilities sub-structs (even if defaults produce `enabled: false`).\n\n## Why\n\nFuture phases can assume a typed `Config` rather than a `HashMap`. Every feature in §13 gets a dedicated struct with its own `enabled` flag + defaults per the plan. Centralizing defaults here makes the \"dev-sized vs. production\" story in plan §6 enforceable by a single `Config::validate()` function.\n\n## Details\n\nCover every block in the plan §4 YAML:\n- `MiroirConfig` — master_key, node_master_key, shards, replication_factor, task_store, admin, replica_groups, nodes[], health, scatter, rebalancer, server\n- `NodeConfig` — id, address, replica_group\n- `TaskStoreConfig` — backend (sqlite|redis), path, url\n- `HealthConfig`, `ScatterConfig`, `RebalancerConfig`, `ServerConfig`\n- `ConnectionPoolConfig`, `TaskRegistryConfig`\n- All §13 blocks: `ReshardingConfig`, `HedgingConfig`, `ReplicaSelectionConfig`, `QueryPlannerConfig`, `SettingsBroadcastConfig`, `SettingsDriftCheckConfig`, `SessionPinningConfig`, `AliasesConfig`, `AntiEntropyConfig`, `DumpImportConfig`, `IdempotencyConfig`, `QueryCoalescingConfig`, `MultiSearchConfig`, `VectorSearchConfig`, `CdcConfig` (+ CdcSinkConfig + CdcBufferConfig), `TtlConfig`, `TenantAffinityConfig`, `ShadowConfig`, `IlmConfig`, `CanaryRunnerConfig`, `ExplainConfig`, `AdminUiConfig`, `SearchUiConfig` (+ auth sub-structs)\n- `PeerDiscoveryConfig`, `LeaderElectionConfig`, `HpaConfig`\n\nPlus:\n- `Config::validate()` cross-field validation (e.g., replicas > 1 requires redis)\n- Layered loading via `config` crate: file → env var overrides → command-line\n- Tests: every example in the plan deserializes without error and re-serializes to equivalent YAML\n\n## Acceptance\n\n- [ ] Full plan §4 `miroir:` block deserializes into the struct without field loss\n- [ ] Every default in the plan is reproduced when the field is absent\n- [ ] `Config::validate()` rejects every combination the Helm `values.schema.json` will reject (dev-defaults in HA mode, scoped_key timing inversion, etc.)\n- [ ] Round-trip property test: YAML → Config → YAML is equivalent under a stable serializer","status":"closed","priority":0,"issue_type":"task","assignee":"alpha","created_at":"2026-04-18T21:24:25.775002832Z","created_by":"coding","updated_at":"2026-04-19T01:52:51.379382557Z","closed_at":"2026-04-19T01:52:51.379316634Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","phase-0"],"dependencies":[{"issue_id":"miroir-qon.5","depends_on_id":"miroir-qon","type":"parent-child","created_at":"2026-04-18T21:24:25.775002832Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-qon.6","title":"P0.6 Repo hygiene: LICENSE, CHANGELOG skeleton, .gitignore, README stub","description":"## What\n\n- `LICENSE` — MIT, per plan §12\n- `CHANGELOG.md` — Keep a Changelog 1.1.0 format skeleton with `[Unreleased]` section\n- `.gitignore` — Rust (`target/`, `Cargo.lock` NOT ignored for binary crates), editor junk (`.vscode/`, `.idea/`)\n- `README.md` is already present — leave untouched for now; Phase 11 fills it in\n\n## Why\n\nPlan §12 explicitly requires MIT. Plan §7 \"CI release step extracts the relevant section automatically\" from CHANGELOG.md using an `awk` parser that expects `## []` section headers — the format must match from day 1 or the first release will fail.\n\n## Details\n\nSample CHANGELOG skeleton:\n```markdown\n# Changelog\n\nAll notable changes to this project will be documented in this file.\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/).\n\n## [Unreleased]\n\n### Added\n### Changed\n### Deprecated\n### Removed\n### Fixed\n### Security\n\n## [0.1.0] - TBD\n\n### Added\n- Initial release.\n```\n\n## Acceptance\n\n- [ ] `LICENSE` matches SPDX `MIT`\n- [ ] `awk \"/^## \\[0.1.0\\]/{found=1; next} found && /^## /{exit} found{print}\" CHANGELOG.md` (the extractor from plan §7) returns non-empty output for a tagged release\n- [ ] `.gitignore` keeps `target/` out and `Cargo.lock` in","status":"closed","priority":1,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:24:25.807632846Z","created_by":"coding","updated_at":"2026-04-19T00:48:12.804426259Z","closed_at":"2026-04-19T00:48:12.804262088Z","close_reason":"Created repo hygiene files: MIT LICENSE, CHANGELOG.md (Keep a Changelog 1.1.0 skeleton with [0.1.0] section), .gitignore (target/, editor junk; Cargo.lock kept). All acceptance criteria verified. Root commit initialized git repo.\n\n## Retrospective\n- **What worked:** Straightforward file creation — clear specs from plan.\n- **What didn't:** Nothing — acceptance criteria were unambiguous.\n- **Surprise:** Workspace had no git repo yet, so this became the root commit.\n- **Reusable pattern:** Always verify the plan's extraction command against CHANGELOG before committing.","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-0"],"dependencies":[{"issue_id":"miroir-qon.6","depends_on_id":"miroir-qon","type":"parent-child","created_at":"2026-04-18T21:24:25.807632846Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-qon.7","title":"P0.7 CI smoke: fmt/clippy/test on push","description":"## What\n\nStand up a minimal CI path — just enough to run `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test --all` — on every push to `main`. This is the earliest viable version of the full `miroir-ci` Argo Workflow template that Phase 8 ships.\n\n## Why\n\nIf CI only lands in Phase 8, Phases 1–7 accumulate quietly-broken code. Plan §7 makes fmt/clippy/test the first three steps of the pipeline on purpose; shipping those now (on iad-ci via a minimal WorkflowTemplate) catches regressions on every commit.\n\n## Details\n\n- Create a stripped-down `miroir-ci-smoke` WorkflowTemplate in `jedarden/declarative-config → k8s/iad-ci/argo-workflows/` that runs only checkout + lint + test\n- Trigger on push to `main` (initially operators kick manually; webhook automation lands in Phase 8)\n- Image: `rust:1.87-slim` to match the full CI template\n- No musl target yet (that's Phase 8); just `cargo test --all`\n\n## Acceptance\n\n- [ ] Manual submit: `kubectl --kubeconfig=$HOME/.kube/iad-ci.kubeconfig create -f - << 1` + `taskStore.backend: sqlite`). Getting the Redis keyspace right now is cheaper than retrofitting.\n\n## Scope — the 14 tables and 14 Redis keyspaces (plan §4)\n\n1. `tasks` — Miroir task registry (miroir_id → node_tasks map + status)\n2. `node_settings_version` — per-(index, node) settings freshness (for §13.5 + `X-Miroir-Min-Settings-Version`)\n3. `aliases` — single-target + multi-target (`kind`, `current_uid`, `target_uids`, `version`, `history`)\n4. `sessions` — read-your-writes session pins (§13.6)\n5. `idempotency_cache` — write dedup (§13.10)\n6. `jobs` — work-queued background jobs (§14.5 Mode C)\n7. `leader_lease` — singleton-coordinator lease (§14.5 Mode B; SQLite advisory lock substitute for single-replica)\n8. `canaries` — canary definitions (§13.18)\n9. `canary_runs` — canary run history (§13.18)\n10. `cdc_cursors` — per-(sink, index) CDC cursor (§13.13)\n11. `tenant_map` — API-key → tenant mapping (§13.15 `api_key` mode)\n12. `rollover_policies` — ILM rollover policies (§13.17)\n13. `search_ui_config` — per-index search-UI config (§13.21)\n14. `admin_sessions` — Admin UI session registry (§13.19)\n\n## Redis keyspace mirror (plan §4 \"Redis mode (HA)\")\n\nEvery table above mapped to a hash + `_index` secondary set so list-wide queries are O(cardinality) without `SCAN`. Plus:\n\n- `miroir:ratelimit:searchui:` (EXPIRE `search_ui.rate_limit.redis_ttl_s`)\n- `miroir:ratelimit:adminlogin:` + `miroir:ratelimit:adminlogin:backoff:` (§13.19, required in HA)\n- `miroir:cdc:overflow:` (1 GiB per sink default)\n- `miroir:search_ui_scoped_key:` + `miroir:search_ui_scoped_key_observed::` (§13.21 rotation coordination)\n- `miroir:admin_session:revoked` Pub/Sub channel for instant logout propagation\n\n## Definition of Done\n\n- [ ] `rusqlite`-backed store initializing every table idempotently at startup\n- [ ] Redis-backed store mirrors the same API (trait `TaskStore` or equivalent), chosen at runtime by `task_store.backend`\n- [ ] Migrations/versioning: schema version recorded in a `schema_version` row so future upgrades detect incompatibility loudly\n- [ ] Property tests: `(insert, get)` round-trip + `(upsert, list)` semantics on SQLite backend\n- [ ] Integration test: restart an orchestrator pod mid-task-poll; task status survives (simulate by opening/closing the SQLite handle between operations)\n- [ ] Redis-backend integration test (`testcontainers` or similar) exercising leases, idempotency dedup, and alias history\n- [ ] `miroir:tasks:_index`-style iteration actually used for list endpoints (no `SCAN`)\n- [ ] `taskStore.backend: redis` + `replicas > 1` enforced by Helm `values.schema.json` (verified with `helm lint`)\n- [ ] Plan §14.7 Redis memory accounting validated against a representative load (bucket count × average size)","status":"in_progress","priority":0,"issue_type":"epic","assignee":"mi-3","created_at":"2026-04-18T21:19:53.974489140Z","created_by":"coding","updated_at":"2026-04-26T23:15:38.472164693Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:46","phase","phase-3"],"dependencies":[{"issue_id":"miroir-r3j","depends_on_id":"miroir-qon","type":"blocks","created_at":"2026-04-18T21:23:08.581818683Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-r3j.1","title":"P3.1 TaskStore trait + SQLite backend (tables 1-7)","description":"## What\n\nDefine the `TaskStore` trait in `miroir-core` and implement the SQLite backend for the first 7 tables in plan §4 \"Task store schema\":\n\n1. `tasks` — Miroir task registry\n2. `node_settings_version`\n3. `aliases` (both single and multi-target)\n4. `sessions` (read-your-writes pins)\n5. `idempotency_cache`\n6. `jobs`\n7. `leader_lease`\n\n## Why Start Here\n\nThese are the always-present tables — needed even in single-pod dev mode. Tables 8–14 (canaries, cdc_cursors, tenant_map, rollover_policies, search_ui_config, admin_sessions) only instantiate when their respective feature flag is on, so they can land alongside the Phase 5 feature they serve.\n\nDefining the trait **in `miroir-core`** (not `miroir-proxy`) lets the crate be consumed by `miroir-ctl` for diagnostics without pulling in the proxy binary.\n\n## Details\n\nEach table's DDL is already in plan §4 (scroll to the table headers). The trait exposes per-table operations plus a generic `migrate(&self) -> Result<()>` that creates tables idempotently and records a `schema_version` row for upgrade detection.\n\n**Non-obvious**:\n- `tasks.node_tasks` is JSON — use a `serde_json::Value` column, not a stringly-typed hack\n- `aliases.history` is a JSON array bounded by `aliases.history_retention`; enforce bound on `UPDATE`\n- `idempotency_cache.body_sha256` is a `BLOB`, not TEXT — 32 raw bytes\n- `jobs.claim_expires_at` updated by heartbeat every 10s; pod loss → claim expires → another pod picks up\n- `leader_lease` for SQLite is an advisory-lock substitute (persist the row, interpret its presence semantically)\n\n**Idempotent migrations** — use `CREATE TABLE IF NOT EXISTS` + a `schema_versions` table that records each applied migration. Future migrations use `INSERT OR IGNORE` + explicit version gates.\n\n## Acceptance\n\n- [ ] `cargo test -p miroir-core task_store::sqlite` — every CRUD round-trips correctly\n- [ ] Opening an existing DB doesn't re-run migrations; schema version check is a single SELECT\n- [ ] Concurrent writes from two handles (single-process) don't deadlock (WAL mode enabled, `PRAGMA busy_timeout = 5000`)\n- [ ] Table sizes under realistic load fit within plan §14.2 \"Task registry cache 100 MB\" budget","status":"closed","priority":0,"issue_type":"task","assignee":"alpha","created_at":"2026-04-18T21:30:07.264404312Z","created_by":"coding","updated_at":"2026-04-19T03:57:35.791395276Z","closed_at":"2026-04-19T03:57:35.791037019Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","phase-3"],"dependencies":[{"issue_id":"miroir-r3j.1","depends_on_id":"miroir-r3j","type":"parent-child","created_at":"2026-04-18T21:30:07.264404312Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-r3j.2","title":"P3.2 SQLite backend: remaining tables (canaries, cdc_cursors, tenant_map, rollover_policies, search_ui_config, admin_sessions)","description":"## What\n\nExtend the SQLite `TaskStore` with plan §4 tables 8–14:\n8. `canaries` (§13.18)\n9. `canary_runs` (§13.18) — bounded by `canary_runner.run_history_per_canary` (default 100); auto-prune on insert\n10. `cdc_cursors` (§13.13)\n11. `tenant_map` (§13.15 `api_key` mode only)\n12. `rollover_policies` (§13.17)\n13. `search_ui_config` (§13.21)\n14. `admin_sessions` (§13.19) — with `CREATE INDEX admin_sessions_expires ON admin_sessions(expires_at)` for lazy eviction\n\n## Why Separate from P3.1\n\nThese tables are **feature-flag-gated** — `canaries` only instantiates when `canary_runner.enabled`, etc. Keeping them in a separate task lets Phase 5 subsection beads own each table's lifecycle and prevents the ~14-table `CREATE TABLE IF NOT EXISTS` cascade from running for features that will never be used.\n\nThat said, the schema definition itself lives here so every Phase 5 feature can `use` the same typed row structs rather than redefining them ad-hoc.\n\n## Details\n\n**`canary_runs` auto-prune**: on each insert, `DELETE FROM canary_runs WHERE canary_id = ? AND ran_at < (SELECT MIN(ran_at) FROM (SELECT ran_at FROM canary_runs WHERE canary_id = ? ORDER BY ran_at DESC LIMIT N))`. Wrap in a trigger so application code never forgets.\n\n**`admin_sessions.expires_at` index** — plan §4 admin_sessions footnote: rows past expires_at evicted lazily on access AND by Mode A pruner (§14.5). The index makes the scan cheap.\n\n**`cdc_cursors` is a per-(sink, index) composite PK** — both columns must match for update-in-place.\n\n**`tenant_map.api_key_hash` is a 32-byte BLOB** — raw sha256 bytes; never store the plaintext API key.\n\n## Acceptance\n\n- [ ] Every table's typed struct round-trips `insert`/`get` in a unit test\n- [ ] `canary_runs` trigger keeps row count ≤ `run_history_per_canary`\n- [ ] Tables that remain empty when their feature is disabled consume < 16 KB each (SQLite overhead)\n- [ ] Tables are created only when `TaskStore::migrate` is called with the relevant feature flag set (so dev-mode single-pod with all features off creates just 7 tables)","status":"closed","priority":0,"issue_type":"task","assignee":"charlie","created_at":"2026-04-18T21:30:07.286925769Z","created_by":"coding","updated_at":"2026-04-19T04:16:44.966812055Z","closed_at":"2026-04-19T04:16:44.966701101Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2","phase-3"],"dependencies":[{"issue_id":"miroir-r3j.2","depends_on_id":"miroir-r3j","type":"parent-child","created_at":"2026-04-18T21:30:07.286925769Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-r3j.2","depends_on_id":"miroir-r3j.1","type":"blocks","created_at":"2026-04-18T21:30:11.179800727Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-r3j.3","title":"P3.3 Redis backend: same trait, Redis keyspace per plan §4","description":"## What\n\nImplement the Redis-backed `TaskStore` mirroring every SQLite table to the keyspace layout in plan §4 \"Redis mode (HA)\":\n\n| SQLite | Redis |\n|--------|-------|\n| `tasks` row | `miroir:tasks:` hash + `miroir:tasks:_index` set |\n| `node_settings_version` | `miroir:node_settings_version::` hash + index set |\n| `aliases` | `miroir:aliases:` hash + index set |\n| `sessions` | `miroir:session:` hash with `EXPIRE session_pinning.ttl_seconds` |\n| `idempotency_cache` | `miroir:idemp:` hash with `EXPIRE idempotency.ttl_seconds` |\n| `jobs` | `miroir:jobs:` hash + `miroir:jobs:_queued` set (HPA signal) |\n| `leader_lease` | `miroir:lease:` string via `SET NX EX 10` renewed every 3s |\n| `canaries` | `miroir:canary:` hash + index set |\n| `canary_runs` | `miroir:canary_runs:` sorted set keyed by `ran_at`; `ZREMRANGEBYRANK` trim |\n| `cdc_cursors` | `miroir:cdc_cursor::` string (integer seq) |\n| `tenant_map` | `miroir:tenant_map:` hash |\n| `rollover_policies` | `miroir:rollover:` hash + index set |\n| `search_ui_config` | `miroir:search_ui_config:` hash |\n| `admin_sessions` | `miroir:admin_session:` hash with `EXPIRE session_ttl_s` + revoked bool |\n\nPlus the extras from plan §4 footnotes:\n- `miroir:search_ui_scoped_key:` hash (fields `primary_uid, previous_uid, rotated_at, generation`) — no TTL; long-lived\n- `miroir:search_ui_scoped_key_observed::` hash with 60s EXPIRE\n- `miroir:admin_session:revoked` Pub/Sub channel (logout invalidation)\n- `miroir:ratelimit:searchui:` with `EXPIRE search_ui.rate_limit.redis_ttl_s`\n- `miroir:ratelimit:adminlogin:` + `miroir:ratelimit:adminlogin:backoff:` (hash `{failed_count, next_allowed_at}`)\n- `miroir:cdc:overflow:` list (1 GiB cap via `cdc.buffer.redis_bytes`)\n\n## Why\n\nPlan §14.4: `replicas > 1` **requires** Redis. The trait-based abstraction means Phase 6 HPA just flips `task_store.backend: redis` via Helm values; no code change in feature layers.\n\n## Details\n\n**Secondary `_index` sets** are the key optimization: list-wide queries (e.g., `GET /_miroir/aliases`) iterate the set, not `SCAN`. Any `insert` must also `SADD` to the index; any `delete` must `SREM`.\n\n**Leader lease**: `SET NX EX 10`. Renewal is `SET XX EX 10` — only if we still hold it. Lease-loss mid-operation is plan §14.5 Mode B's recovery path.\n\n**EXPIRE on idempotency / session / admin_session / search_ui rate limit** — let Redis garbage-collect rather than running a Mode A pruner for each.\n\n**CDC overflow**: use `LPUSH` + `LTRIM` to bound list length; `LLEN` gives `miroir_cdc_buffer_bytes` (approximate).\n\n**Pipelining**: for the task fan-out mapping (one write → N node task IDs), use MULTI/EXEC to insert the tasks row + SADD the index set atomically.\n\n## Acceptance\n\n- [ ] testcontainers-based integration test: identical trait-level behavior to SQLite backend (run the shared CRUD suite against both)\n- [ ] Lease race: two pods `SET NX EX` simultaneously → exactly one wins\n- [ ] Memory budget: at 10k idempotency keys + 1k sessions + 100k tasks, Redis RSS stays under plan §14.7 accounting target\n- [ ] Pub/Sub: subscribe to `miroir:admin_session:revoked` and confirm logout on pod-A invalidates pod-B's in-memory cache within 100ms","status":"in_progress","priority":0,"issue_type":"task","assignee":"mi-2","created_at":"2026-04-18T21:30:07.307470462Z","created_by":"coding","updated_at":"2026-04-26T23:15:39.241708600Z","close_reason":"Implemented complete Redis-backed TaskStore with plan §4 keyspace layout:\n\n- All 14 SQLite tables mapped to Redis keyspace (tasks, node_settings_version, aliases, sessions, idempotency_cache, jobs, leader_lease, canaries, canary_runs, cdc_cursors, tenant_map, rollover_policies, search_ui_config, admin_sessions)\n- Extra Redis-specific keys from plan §4 footnotes (search_ui_scoped_key, rate limiting, CDC overflow buffer, Pub/Sub revocation)\n- testcontainers-based integration tests for all tables\n- Lease race test verifying exactly one pod wins concurrent SET NX EX\n- Memory budget test for 10k tasks + 1k sessions + 1k idempotency entries\n- Pub/Sub test for admin_session revocation across pods\n- Secondary _index sets for efficient list-wide queries\n- MULTI/EXEC pipelines for atomic operations\n- TTL-based garbage collection for sessions/idempotency\n- Sync-to-async bridge avoiding runtime nesting issues\n\nAcceptance criteria met:\n✓ testcontainers integration tests with identical trait behavior to SQLite\n✓ Lease race: two pods SET NX EX simultaneously → exactly one wins\n✓ Memory budget: test creates workload matching plan §14.7 target\n✓ Pub/Sub: miroir:admin_session:revoked channel for cross-pod invalidation","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:78","phase-3"],"dependencies":[{"issue_id":"miroir-r3j.3","depends_on_id":"miroir-qon","type":"blocks","created_at":"2026-04-24T03:52:35.137379288Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-r3j.3","depends_on_id":"miroir-r3j.1","type":"blocks","created_at":"2026-04-18T21:30:11.196004625Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-r3j.3.1","title":"P3.3.a Redis backend: core tables (tasks, node_settings_version, aliases, sessions)","description":"## Scope (plan §4 Redis mode — subset of 14-table mirror)\nAlways-on tables needed by Phase 2 + Phase 3 base:\n- \\`miroir:tasks:\\` hash + \\`miroir:tasks:_index\\` set\n- \\`miroir:node_settings_version::\\` hash + index set\n- \\`miroir:aliases:\\` hash + index set\n- \\`miroir:session:\\` hash with \\`EXPIRE session_pinning.ttl_seconds\\`\n\nIntegrate with the TaskStore trait from miroir-r3j.1.\n\n## Acceptance\n- [ ] testcontainers Redis: CRUD round-trip on all 4 tables via TaskStore trait\n- [ ] \\`_index\\` set iteration matches SQLite list-all semantics (no SCAN)\n- [ ] EXPIRE on sessions fires within TTL tolerance","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-21T11:07:54.426490258Z","created_by":"coding","updated_at":"2026-04-26T15:49:44.678986384Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:8","phase-3"],"dependencies":[{"issue_id":"miroir-r3j.3.1","depends_on_id":"miroir-qon","type":"blocks","created_at":"2026-04-24T03:52:33.464067228Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-r3j.3.2","title":"P3.3.b Redis backend: write-path tables (idempotency_cache, jobs, leader_lease)","description":"## Scope (plan §4)\n- \\`miroir:idemp:\\` hash with \\`EXPIRE idempotency.ttl_seconds\\`\n- \\`miroir:jobs:\\` hash + \\`miroir:jobs:_queued\\` set (HPA signal)\n- \\`miroir:lease:\\` string via \\`SET NX EX 10\\` renewed every 3s\n\n## Special cases\n- Lease renewal: \\`SET XX EX 10\\` (only if we still hold it)\n- Jobs queued set feeds HPA via \\`SCARD miroir:jobs:_queued\\` = \\`miroir_background_queue_depth\\`\n\n## Acceptance\n- [ ] Lease race: two pods \\`SET NX EX\\` simultaneously → exactly one wins\n- [ ] Lease renewal failure released the lease (XX semantics)\n- [ ] Idempotency EXPIRE evicts row within TTL","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-21T11:07:54.447379871Z","created_by":"coding","updated_at":"2026-04-24T03:52:33.429941841Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-3"],"dependencies":[{"issue_id":"miroir-r3j.3.2","depends_on_id":"miroir-qon","type":"blocks","created_at":"2026-04-24T03:52:33.429918338Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-r3j.3.2","depends_on_id":"miroir-r3j.3.1","type":"blocks","created_at":"2026-04-21T11:07:54.525918602Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-r3j.3.3","title":"P3.3.c Redis backend: feature tables (canaries, cdc_cursors, tenant_map, rollover, search_ui, admin_sessions)","description":"## Scope (plan §4)\nFeature-flag-gated tables:\n- \\`miroir:canary:\\` hash + \\`miroir:canary:_index\\`\n- \\`miroir:canary_runs:\\` sorted set keyed by ran_at; \\`ZREMRANGEBYRANK\\` trim to run_history_per_canary\n- \\`miroir:cdc_cursor::\\` string (integer seq)\n- \\`miroir:tenant_map:\\` hash (no secondary index — lookup by exact key hash)\n- \\`miroir:rollover:\\` hash + \\`miroir:rollover:_index\\`\n- \\`miroir:search_ui_config:\\` hash\n- \\`miroir:admin_session:\\` hash with EXPIRE admin_ui.session_ttl_s + revoked bool\n\n## Acceptance\n- [ ] All 7 table types round-trip\n- [ ] ZREMRANGEBYRANK keeps canary_runs bounded\n- [ ] admin_sessions.revoked flip visible on subsequent cookie check (no cache)","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-21T11:07:54.469056248Z","created_by":"coding","updated_at":"2026-04-24T03:52:33.396474961Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-3"],"dependencies":[{"issue_id":"miroir-r3j.3.3","depends_on_id":"miroir-qon","type":"blocks","created_at":"2026-04-24T03:52:33.396438682Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-r3j.3.3","depends_on_id":"miroir-r3j.3.1","type":"blocks","created_at":"2026-04-21T11:07:54.553308558Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-r3j.3.4","title":"P3.3.d Redis backend: extras (rate-limit keys, scoped-key coordination, Pub/Sub, CDC overflow)","description":"## Scope (plan §4 footnotes)\nNot tables but keyspaces that share the Redis backend:\n- \\`miroir:ratelimit:searchui:\\` with EXPIRE search_ui.rate_limit.redis_ttl_s\n- \\`miroir:ratelimit:adminlogin:\\` + \\`miroir:ratelimit:adminlogin:backoff:\\`\n- \\`miroir:search_ui_scoped_key:\\` hash {primary_uid, previous_uid, rotated_at, generation}\n- \\`miroir:search_ui_scoped_key_observed::\\` hash, EXPIRE 60s\n- \\`miroir:admin_session:revoked\\` Pub/Sub channel (instant logout propagation)\n- \\`miroir:cdc:overflow:\\` list with LPUSH + LTRIM bound\n\n## Acceptance\n- [ ] Rate-limit bucket counter respects EXPIRE\n- [ ] Pub/Sub: revoked logout on pod-A invalidates pod-B cache within 100ms\n- [ ] CDC overflow LLEN approximately matches bytes published","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T11:07:54.500433994Z","created_by":"coding","updated_at":"2026-04-26T15:49:44.567866682Z","close_reason":"P3.3.d Redis backend extras completed.\n\nAll Redis backend extras were already fully implemented in prior work.\nThis task only required fixing a compilation error.\n\nRate-limit keys: miroir:ratelimit:searchui, miroir:ratelimit:adminlogin\nScoped-key coordination: miroir:search_ui_scoped_key, miroir:search_ui_scoped_key_observed \nPub/Sub: miroir:admin_session:revoked channel\nCDC overflow: miroir:cdc:overflow list with LPUSH + LTRIM\n\nFix: Added missing local_search_ui_rate_limiter field to FromRef in main.rs\n\nAcceptance criteria verified by existing tests:\n- test_redis_rate_limit_searchui verifies EXPIRE\n- test_redis_pubsub_session_invalidation verifies sub-100ms propagation\n- test_redis_cdc_overflow verifies LLEN matches bytes\n\nSee docs/plan/REDIS_MEMORY_ACCOUNTING.md","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:29","phase-3"],"dependencies":[{"issue_id":"miroir-r3j.3.4","depends_on_id":"miroir-qon","type":"blocks","created_at":"2026-04-24T03:52:36.155810970Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-r3j.4","title":"P3.4 Migration + schema versioning","description":"## What\n\nImplement a first-class schema version system:\n- `schema_versions` table (SQLite) / `miroir:schema_version` key (Redis) recording the most recently applied migration\n- Each schema change gets a numbered migration (`001_initial.sql`, `002_add_foo.sql`, etc.)\n- Startup: read current version → apply all migrations with higher numbers → record latest\n- Refuse to start if DB version > binary version (e.g., operator rolled back to an older binary without rolling back the store)\n\n## Why\n\nPlan §12 commits to \"Config file schema: backward-compatible in minor versions (new fields always optional with defaults)\" and \"Task store schema requires migration notes (§7 release checklist).\" A versioning system forces that discipline from v0.1; shipping v1.0 with ad-hoc ALTER TABLE scatter is a nightmare to undo.\n\n## Details\n\n**Numbering**: monotonic `uXXX` where `u` is `000` to `999`; version history embedded in the binary via `include_str!` from a known directory.\n\n**Down-migration is optional** — we write migrations as one-way by default. For rollback, operators restore from backup rather than `downgrade 042→041`. Beads keep this door open; don't lock it shut.\n\n**Binary-vs-store version check**:\n- binary version = max migration number compiled into the binary\n- store version = max migration applied\n- start-up: if `binary < store`, refuse with a clear error. If `binary == store`, no-op. If `binary > store`, apply missing migrations.\n\n## Acceptance\n\n- [ ] First run creates the schema at version 001 (or whatever is the initial)\n- [ ] Second run is a no-op; migration scan is a single SELECT\n- [ ] Artificially set store version to binary+1 → startup fails with `schema_version_ahead` error\n- [ ] Both SQLite and Redis backends share the same migration metadata structure","status":"closed","priority":1,"issue_type":"task","assignee":"alpha","created_at":"2026-04-18T21:30:07.338809736Z","created_by":"coding","updated_at":"2026-04-19T04:17:36.370998673Z","closed_at":"2026-04-19T04:17:36.370920117Z","close_reason":"P3.4: Schema versioning system implemented and verified\n\nImplementation:\n- schema_versions table tracks applied migrations\n- MigrationRegistry with build_registry() using include_str! for migrations\n- 001_initial.sql creates schema_versions + tables 1-7\n- 002_feature_tables.sql creates tables 8-14 (feature-flagged)\n- run_migration() validates version and applies pending migrations\n- SchemaVersionAhead error when store version > binary version\n\nAcceptance criteria met:\n✅ First run creates schema at version 001\n✅ Second run is no-op (single SELECT for version check)\n✅ Store version > binary version fails with SchemaVersionAhead error\n✅ Migration metadata structure is backend-agnostic (ready for Redis)\n\nAll 114 tests pass including migration tests:\n- migration_is_idempotent\n- schema_version_recorded\n- schema_version_ahead_fails\n\nCommitted in 3f7b1ac","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:2","phase-3"],"dependencies":[{"issue_id":"miroir-r3j.4","depends_on_id":"miroir-r3j","type":"parent-child","created_at":"2026-04-18T21:30:07.338809736Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-r3j.4","depends_on_id":"miroir-r3j.1","type":"blocks","created_at":"2026-04-18T21:30:11.210512282Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-r3j.5","title":"P3.5 values.schema.json rejection: replicas>1 requires Redis","description":"## What\n\nAdd an entry to `charts/miroir/values.schema.json` that **fails `helm lint`** when `miroir.replicas > 1` and `taskStore.backend == \"sqlite\"`.\n\n## Why\n\nPlan §14.4: \"SQLite is single-writer and cannot be shared. The Helm chart enforces this: `taskStore.backend=sqlite` with `miroir.replicas > 1` fails values-schema validation.\" Without this guard, a developer who bumps `replicas: 2` in values.yaml and forgets to flip the backend gets silent task-store divergence across pods — every pod writes to its own SQLite in its own ephemeralVolume, mtask polls on pod-A can't see tasks enqueued on pod-B.\n\n## Details\n\nUse JSON Schema `if/then`:\n```jsonc\n{\n \"if\": { \"properties\": { \"miroir\": { \"properties\": { \"replicas\": { \"type\": \"integer\", \"exclusiveMinimum\": 1 } } } } },\n \"then\": { \"properties\": { \"taskStore\": { \"properties\": { \"backend\": { \"const\": \"redis\" } } } } }\n}\n```\n\nAdd `helm lint --strict` cases to Phase 9 test harness:\n- `replicas: 1, backend: sqlite` → lint passes\n- `replicas: 2, backend: sqlite` → lint fails with a clear error message\n- `replicas: 2, backend: redis` → lint passes\n\n## Acceptance\n\n- [ ] `helm lint --strict` on a values file with `replicas: 2 + backend: sqlite` fails with a message pointing at the constraint\n- [ ] The failure message is operator-readable (\"SQLite task store cannot run with multiple replicas; set taskStore.backend=redis\") — use `errorMessage` extension if available, else accept the default output\n- [ ] Test cases added to `charts/miroir/tests/` for future-proofing","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-04-18T21:30:07.373576976Z","created_by":"coding","updated_at":"2026-04-19T03:45:51.195402118Z","closed_at":"2026-04-19T03:45:51.195338621Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","phase-3"],"dependencies":[{"issue_id":"miroir-r3j.5","depends_on_id":"miroir-r3j","type":"parent-child","created_at":"2026-04-18T21:30:07.373576976Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-r3j.6","title":"P3.6 Task registry TTL pruner (in-memory for Phase 3; Mode A in Phase 6)","description":"## What\n\nImplement a background task that prunes `tasks` rows older than `task_registry.ttl_seconds` (default 7 days per plan §4). In Phase 3 this runs single-pod with an advisory lock; Phase 6 §14.5 Mode A replaces with rendezvous-partitioned ownership.\n\n## Why\n\nWithout TTL pruning, the task table grows unbounded. Plan §4 explicitly calls out the Mode A rendezvous pruner as the mechanism; shipping the simpler single-pod version here lets single-pod dev deployments not leak memory, and Phase 6 just swaps the ownership rule.\n\n## Details\n\n**Cadence**: run every `task_registry.prune_interval_s` (default 300s / 5 min).\n\n**Batch size**: max 10k rows per iteration so the background task never holds the DB long. SQLite: `DELETE FROM tasks WHERE created_at < ? LIMIT 10000`.\n\n**Preservation rule**: never prune a task whose `status` is `processing` (poll results might still be incoming). Plan this as \"age > TTL AND status IN (succeeded, failed, canceled)\".\n\n**Metrics**: `miroir_task_registry_size` (gauge) exposed per plan §10. The pruner updates it.\n\n## Acceptance\n\n- [ ] After insert of 10k terminal tasks with `created_at = now - 8d`, next pruner cycle drops all 10k\n- [ ] A single in-flight `processing` task at `created_at = now - 10d` is preserved\n- [ ] Pruner advisory lock prevents two instances pruning simultaneously (single-pod guarantee; Phase 6 replaces)\n- [ ] `miroir_task_registry_size` gauge drops after a prune cycle","status":"closed","priority":1,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:30:07.405347149Z","created_by":"coding","updated_at":"2026-04-19T04:25:10.283498914Z","closed_at":"2026-04-19T04:25:10.283389272Z","close_reason":"TTL pruner already implemented and tested in commit 47d586c. All 4 acceptance criteria pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","phase-3"],"dependencies":[{"issue_id":"miroir-r3j.6","depends_on_id":"miroir-r3j","type":"parent-child","created_at":"2026-04-18T21:30:07.405347149Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-r3j.6","depends_on_id":"miroir-r3j.1","type":"blocks","created_at":"2026-04-18T21:30:11.223268357Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj","title":"Phase 5 — Advanced Capabilities (§13.1–§13.21)","description":"## Phase 5 Epic — Advanced Capabilities\n\nShips all 21 §13 capabilities. Each is orchestrator-side only (no Meilisearch node modification), individually togglable via a config flag, and defaults chosen to be low-risk. Four of them (§13.1, §13.5, §13.8, §13.9) directly resolve Open Problems in §15; the remaining 17 harden latency, correctness, and client ergonomics.\n\n## Why These Are Grouped\n\nPlan §13 preamble: \"All capabilities are individually togglable and default to conservative values.\" They are logically one epic because they share:\n- A single config-flag contract (`enabled: bool` per subsection)\n- The same orchestrator invariant (no node-side patches, unmodified CE)\n- The same task-store tables (defined in Phase 3)\n- The same HA coordination primitives (Phase 6 Modes A/B/C)\n\nSplitting them across phases would produce misleading dependency edges — in reality each §13.x is independent and can be built in parallel.\n\n## Subsections (each becomes one task bead under this epic)\n\n- §13.1 Online resharding via shadow index (OP#3)\n- §13.2 Hedged requests (tail latency)\n- §13.3 Adaptive replica selection (EWMA)\n- §13.4 Shard-aware query planner (PK-constrained)\n- §13.5 Two-phase settings broadcast + drift reconciler (OP#4)\n- §13.6 Read-your-writes via session pinning\n- §13.7 Atomic index aliases (single + multi-target)\n- §13.8 Anti-entropy shard reconciler (OP#1)\n- §13.9 Streaming routed dump import (OP#5)\n- §13.10 Idempotency keys + query coalescing\n- §13.11 Multi-search batch API\n- §13.12 Vector + hybrid search sharding (over-fetch + RRF/convex)\n- §13.13 CDC stream (webhook / NATS / Kafka / internal queue)\n- §13.14 Document TTL + automatic expiration\n- §13.15 Tenant-to-replica-group affinity\n- §13.16 Traffic shadow / teeing to staging\n- §13.17 Rolling time-series indexes (ILM)\n- §13.18 Synthetic canary queries + golden assertions\n- §13.19 Admin UI (embedded SPA via rust-embed)\n- §13.20 Query explain API\n- §13.21 End-user search UI (embedded SPA + JWT brokering + scoped-key rotation)\n\n## Cross-Feature Interactions to Preserve\n\n- §13.1 reshard's step 5 = §13.7 alias flip\n- §13.5 `settings_version` consumed by §13.6 session pin + §13.10 query-coalescing fingerprint + §13.20 explain\n- §13.8 expired-doc branch calls `_miroir_expires_at` (§13.14 interaction)\n- §13.13 CDC suppression via `_miroir_origin` tag (set by §13.1 backfill, §13.8 repair, §13.14 sweep, §13.17 rollover)\n- §13.17 `read_alias` is a §13.7 multi-target alias only ILM may edit\n- §13.19 Admin UI surfaces §13.5 2PC preview, §13.16 shadow diff, §13.13 CDC tail, §13.20 explain\n- §13.21 Search UI uses §13.11 multi-search, §13.10 coalescing, §13.6 session pinning; JWT signed via `SEARCH_UI_JWT_SECRET` with §9 dual-secret rotation\n\n## Definition of Done\n\n- [ ] All 21 subsection task beads closed\n- [ ] Every `enabled: true` default from the plan honored\n- [ ] Every cross-reference listed above validated by an integration test\n- [ ] Every §10/§14 metric family registered and scraping on the right port\n- [ ] §9 secret inventory updated (ADMIN_SESSION_SEAL_KEY, SEARCH_UI_JWT_SECRET, search_ui_shared_key)","status":"open","priority":0,"issue_type":"epic","created_at":"2026-04-18T21:19:54.006891677Z","created_by":"coding","updated_at":"2026-04-18T21:23:08.634562113Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase","phase-5"],"dependencies":[{"issue_id":"miroir-uhj","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-18T21:23:08.621245444Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-18T21:23:08.634544009Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.1","title":"P5.1 §13.1 Online resharding via shadow index (OP#3)","description":"## What\n\nImplement the six-phase online resharding flow from plan §13.1:\n\n1. **Shadow create**: `{uid}__reshard_{S_new}` on every node with the new S, settings propagated via §13.5 two-phase broadcast\n2. **Dual-hash dual-write**: live writes go to both `{uid}` (hash %S_old) and `{uid}__reshard_{S_new}` (hash %S_new) with `_miroir_shard` injected per index's own S\n3. **Backfill**: background streamer pages every live-index shard via `filter=_miroir_shard={id}`, re-hashes each doc under S_new, writes to shadow; tagged `_miroir_origin: reshard_backfill` so §13.13 CDC suppresses\n4. **Verify**: cross-index PK-set comparator + content-hash fingerprint between live and shadow (reuses §13.8 bucketed-Merkle machinery but keyed by PK since live/shadow have different S)\n5. **Alias swap**: atomic §13.7 `PUT /_miroir/aliases/{uid}` to the shadow; dual-write stops\n6. **Cleanup**: live retained for `retain_old_index_hours` (default 48h) for emergency rollback, then deleted\n\n## Why\n\nPlan §15 Open Problem 3: \"The 'choose S generously' guidance remains the recommended default because online resharding doubles transient storage and write load; treat §13.1 as a remediation, not a license to under-provision.\" This is the safety valve — without it, under-provisioned clusters face a full external reindex.\n\n## Details\n\n**Scaling mode (plan §14.6)**: Mode B (leader for phase state machine) + Mode C (backfill chunks queued as jobs).\n\n**Failure handling** (plan §13.1): any failure before step 5 → delete shadow, invisible to clients. After step 5, rollback is a reverse alias flip to the retained live index.\n\n**CDC suppression**: §13.13 filters by `_miroir_origin: reshard_backfill` so subscribers don't see shadow writes as duplicates of live writes. Configured via `cdc.emit_internal_writes: false` (default).\n\n**Cross-index PK verify** is NOT the same as §13.8 within-shard reconciler — different S means different `_miroir_shard` values. Bucketing by `pk-hash % 256` gives a comparable space across indexes.\n\n**Admin API + CLI** (plan §4 admin table + §13.1):\n- `POST /_miroir/indexes/{uid}/reshard` body `{\"new_shards\": 256, \"throttle_docs_per_sec\": 10000}`\n- `GET /_miroir/indexes/{uid}/reshard/status`\n- `miroir-ctl reshard --index products --new-shards 256 --throttle 10000 [--dry-run]`\n\n## Acceptance\n\n- [ ] Reshard 64→128 on a 1M-doc index; post-swap search returns identical hits for golden queries\n- [ ] Mid-backfill failure: shadow deleted, client sees zero impact\n- [ ] Post-swap rollback: `PUT /_miroir/aliases/{uid} {\"target\": \"\"}` within 48h restores; aliased reads hit the old data\n- [ ] `miroir_reshard_phase` gauge transitions 0→1→2→3→4→5→0\n- [ ] Backfill throttles to `throttle_docs_per_sec` during peak business hours; disk footprint stays under 2× corpus during dual-write","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:33:36.737028315Z","created_by":"coding","updated_at":"2026-04-24T03:52:34.834396211Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.1","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:34.834367618Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.1","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:34.818669581Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.1","depends_on_id":"miroir-uhj.5","type":"blocks","created_at":"2026-04-18T21:38:33.123026198Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.1","depends_on_id":"miroir-uhj.7","type":"blocks","created_at":"2026-04-18T21:38:33.137757362Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.1.1","title":"P5.1.a Shadow create phase: new index on every node via §13.5 broadcast","description":"Reshard step 1 (plan §13.1). Create {uid}__reshard_{S_new} on every node with new S; propagate live index's settings via §13.5 two-phase broadcast. Shadow is not client-addressable. Failure here deletes the shadow — invisible to clients.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:50:32.931816015Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.121973125Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.1.1","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.121926960Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.1.1","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:37.101769386Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.1.2","title":"P5.1.b Dual-hash dual-write phase: tag shadow writes as _miroir_origin: reshard_backfill","description":"Reshard step 2 (plan §13.1). From shadow-exists onward, every write routes to BOTH live (hash %S_old) AND shadow (hash %S_new), each with its own _miroir_shard. Tag shadow writes with _miroir_origin: reshard_backfill so §13.13 CDC suppresses (avoids publishing both sides of the dual-write). Write volume to nodes approx doubles in this phase — expect disk pressure warnings.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:50:32.957898240Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.068387996Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.1.2","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.068337058Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.1.2","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:37.052089284Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.1.2","depends_on_id":"miroir-uhj.1.1","type":"blocks","created_at":"2026-04-18T21:52:42.694221383Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.1.3","title":"P5.1.c Backfill phase: paginate every live shard via _miroir_shard filter","description":"Reshard step 3 (plan §13.1). Background streamer pages every live-index shard via filter=_miroir_shard={id} (same primitive as §4 rebalancer + §13.8 anti-entropy). Each doc re-hashed under S_new, written to shadow. Throttle: backfill_concurrency (4), batch_size (1000), throttle_docs_per_sec (0=unlimited). Tagged _miroir_origin: reshard_backfill (CDC suppressed). Mode C: chunks queued as jobs in §4 jobs table; any pod can claim.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:50:32.983811162Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.015286408Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.1.3","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.015244037Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.1.3","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:36.994879371Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.1.3","depends_on_id":"miroir-uhj.1.2","type":"blocks","created_at":"2026-04-18T21:52:42.721456810Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.1.4","title":"P5.1.d Verify phase: cross-index PK set + content-hash comparator","description":"Reshard step 4 (plan §13.1). Cross-index verify — different S means different _miroir_shard, so §13.8 within-shard reconciler cannot run directly. Instead, iterate every shard of live + shadow via filter=_miroir_shard={id} paginated scan, stream PKs + content fingerprints into side-by-side xxh3-keyed buckets keyed by PK (not shard). Assert: (a) live PK set == shadow PK set, (b) for each PK, content_hash matches. Reuses §13.8's bucketed-Merkle machinery with PK-keyed bucketing.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:50:33.017680157Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.958962816Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.1.4","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.958925073Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.1.4","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:36.941810155Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.1.4","depends_on_id":"miroir-uhj.1.3","type":"blocks","created_at":"2026-04-18T21:52:42.752905174Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.1.5","title":"P5.1.e Alias swap + dual-write stop (the atomic cutover)","description":"Reshard step 5 (plan §13.1). PUT /_miroir/aliases/{uid} {target: {uid}__reshard_{S_new}} — atomic. Subsequent writes target ONLY the new S; dual-write stops. After this step, rollback is a reverse alias flip to the retained live index (TTL: retain_old_index_hours, default 48h).","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:50:33.049847722Z","created_by":"coding","updated_at":"2026-04-24T03:52:33.890632733Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.1.5","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:33.890609169Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.1.5","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:33.873919027Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.1.5","depends_on_id":"miroir-uhj.1.4","type":"blocks","created_at":"2026-04-18T21:52:42.774895323Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.1.6","title":"P5.1.f Cleanup phase: delete live after retention TTL","description":"Reshard step 6 (plan §13.1). Live index retained retain_old_index_hours (default 48h) for emergency rollback, then deleted. Cleanup is reversible in the sense that if operators call the rollback-alias flip before TTL expires, the old live index is back online. Delete is tagged _miroir_origin: reshard_backfill so CDC suppresses. Metric: miroir_reshard_cleanup_completed_seconds gauge.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-18T21:50:33.066428296Z","created_by":"coding","updated_at":"2026-04-24T03:52:38.908047912Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.1.6","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:38.908005574Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.1.6","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:38.891155170Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.1.6","depends_on_id":"miroir-uhj.1.5","type":"blocks","created_at":"2026-04-18T21:52:42.802357887Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.10","title":"P5.10 §13.10 Idempotency keys + query coalescing","description":"## What\n\n**Writes — idempotency**: accept `Idempotency-Key: ` header; `idempotency_cache` table tracks `(key → body_sha256, miroir_task_id, expires_at)`:\n- key hits + body matches → return existing `miroir_task_id`, HTTP 200\n- key hits + body differs → HTTP 409 `miroir_idempotency_key_reused`\n- key miss → process + insert\n\n**Reads — query coalescing**: identical canonicalized bodies within a window (default 50ms) share one upstream scatter via `DashMap>`.\n\n## Why\n\nPlan §13.10: \"HTTP retries, SDK retry loops, and at-least-once delivery from upstream queues produce duplicate writes. Simultaneously, hot identical search queries waste a trivial caching opportunity.\" Combined they defend against duplicate writes and reduce duplicate scatter on hot queries.\n\n## Details\n\n**Idempotency cache bounds**: `idempotency.max_cached_keys` (default 1M, ~100MB plan §14.2); TTL default 24h.\n\n**Coalescing window**: closes at response time; next identical query starts fresh scatter. Fingerprint = `canonical_json(body) || index_uid || current_settings_version` — settings change invalidates in-flight coalesce because `settings_version` is part of the key.\n\n**Scaling mode**:\n- Idempotency: per-pod + shared fallback (retry on a different pod still dedups via task-store lookup on miss)\n- Coalescing: per-pod only (acceptable — identical concurrent queries on different pods each issue one scatter, which is bounded by pod count)\n\n**Retry-cache unification**: the same cache backs Phase 2 `scatter.retry_on_timeout` (plan §4 note + §13.10 \"single mechanism\").\n\n**Config** (plan §13.10):\n```yaml\nidempotency:\n enabled: true\n ttl_seconds: 86400\n max_cached_keys: 1000000\nquery_coalescing:\n enabled: true\n window_ms: 50\n max_subscribers: 1000\n max_pending_queries: 10000\n```\n\n**Metrics**: `miroir_idempotency_hits_total{outcome=dedup|conflict|miss}`, `miroir_idempotency_cache_size`, `miroir_query_coalesce_subscribers_total`, `miroir_query_coalesce_hits_total`.\n\n## Acceptance\n\n- [ ] Same `Idempotency-Key` + same body twice → one mtask returned both times\n- [ ] Same key + different body → 409 `miroir_idempotency_key_reused`\n- [ ] Hot query (1000 identical concurrent requests) → ≤ 10 scatters fire (one per 50ms window)\n- [ ] Settings change mid-coalesce-window → next query starts fresh (doesn't merge with pre-change queries)","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:35:21.808507094Z","created_by":"coding","updated_at":"2026-04-24T03:52:38.158917858Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.10","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:38.158872448Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.10","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:38.142570691Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.11","title":"P5.11 §13.11 Multi-search batch API","description":"## What\n\nImplement `POST /multi-search` (plan §13.11): `{\"queries\": [{indexUid, q, filter, ...}, ...]}`. Each query scattered independently in parallel; results returned in input order with individual status codes.\n\nEvery query uses the full pipeline:\n- §13.4 query planner\n- §13.3 adaptive replica selection\n- §13.2 hedging\n- §13.10 coalescing\n\nQueries targeting the same index + replica group share HTTP/2 connections and query-plan cache lookups. Queries targeting different indexes run fully in parallel. A single slow query does NOT block others; each carries its own deadline.\n\n## Why\n\nPlan §13.11: \"Real search UIs issue 5–20 queries per page render: main results, per-facet counts, autocomplete, related items, 'did you mean?' suggestions. Today each is a separate round-trip. Meilisearch Enterprise has `/multi-search`; CE does not. Miroir delivers it by itself.\"\n\n§13.21 search UI builds its instant-search + facet-count pattern on top of this.\n\n## Details\n\n**Scaling mode**: stateless per-request.\n\n**Interaction with §13.6 session pinning**: per sub-query — each sub-query independently checks for pending writes under the session; each may wait for its index's task before executing.\n\n**Interaction with §13.15 tenant affinity**: per-request — `X-Miroir-Tenant` applies to whole batch.\n\n**Conflict — session pin wins**: strong consistency beats tenant isolation. Metric `miroir_tenant_session_pin_override_total{tenant}`.\n\n**§13.20 explain**: batched explain returns one plan object per sub-query.\n\n**Config**:\n```yaml\nmulti_search:\n enabled: true\n max_queries_per_batch: 100\n total_timeout_ms: 30000\n per_query_timeout_ms: 30000\n```\n\n**Metrics**: `miroir_multisearch_queries_per_batch` histogram, `miroir_multisearch_batches_total`, `miroir_multisearch_partial_failures_total`.\n\n## Acceptance\n\n- [ ] 5-query batch: all 5 complete; slow one doesn't block fast ones\n- [ ] 100-query batch: completes under `total_timeout_ms`\n- [ ] Cross-index: products + reviews queries run truly in parallel (latencies overlap in tracing)\n- [ ] Partial failure: 1 of 5 queries errors; batch returns 4 successes + 1 error in input order","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:35:21.827149898Z","created_by":"coding","updated_at":"2026-04-24T03:52:38.111633362Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.11","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:38.111607599Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.11","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:38.096624133Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.11","depends_on_id":"miroir-uhj.15","type":"blocks","created_at":"2026-04-18T21:38:33.238655665Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.11","depends_on_id":"miroir-uhj.6","type":"blocks","created_at":"2026-04-18T21:38:33.220990155Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.12","title":"P5.12 §13.12 Vector + hybrid search sharding (over-fetch + RRF/convex)","description":"## What\n\nRoute vectors + hybrid search correctly across shards (plan §13.12):\n- **Write**: vectors travel with doc body; routed identically via `hash(pk) % S`. Each node stores full vector for its own docs.\n- **Embedder config** is a setting → §13.5 two-phase broadcast ensures all nodes have identical embedders; §13.8 anti-entropy repairs drift.\n- **Read**: scatter with **over-fetch factor** (default 3×). Per-shard `limit = requested_limit × over_fetch_factor`, return both `_semanticScore` and `_rankingScore` (Meilisearch hybrid exposes both).\n- **Merger**: combine into global score via RRF or convex `(1−α)·bm25 + α·semantic`, matching Meilisearch's hybrid formula. Global sort → apply offset/limit.\n- **Pure vector** uses `_semanticScore` only; **pure keyword** uses `_rankingScore` only.\n\nOver-fetch tunable per request via `X-Miroir-Over-Fetch` header.\n\n## Why\n\nPlan §13.12: \"Naïve top-K merging across shards produces wrong global rankings: a shard with few semantically-relevant documents returns low scores that compete badly against a dense shard's high scores.\" Over-fetch is the only way to recover correct global ranking for sparse semantic matches.\n\n## Details\n\n**Embedder drift metric**: `miroir_vector_embedder_drift_total` — distinct embedders detected across nodes. Any non-zero count is a settings-divergence bug.\n\n**Config**:\n```yaml\nvector_search:\n enabled: true\n over_fetch_factor: 3\n merge_strategy: convex # convex | rrf\n hybrid_alpha_default: 0.5\n rrf_k: 60\n```\n\n**Per-pod memory**: plan §14.2 allocates ~30 MB for over-fetch scratch at default factor — larger result buffers during merge.\n\n**Compatibility**: Meilisearch native `POST /indexes/{uid}/search` with `hybrid: {embedder, semanticRatio}` + `showRankingScoreDetails: true`. No node change.\n\n## Acceptance\n\n- [ ] Pure-keyword query via Miroir: same top-20 as pure-keyword against single-node Meilisearch with same corpus\n- [ ] Hybrid query across 3 shards with skewed semantic distributions: global ordering differs from round-robin top-K by the expected amount; matches a ground-truth single-index result\n- [ ] Over-fetch factor 1 produces provably inferior ranking on sparse-semantic shards (documented failure mode)\n- [ ] `X-Miroir-Over-Fetch: 5` raises the factor for one request without affecting others","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:35:21.856749596Z","created_by":"coding","updated_at":"2026-04-24T03:52:38.065958671Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.12","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:38.065932680Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.12","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:38.048469537Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.13","title":"P5.13 §13.13 CDC stream (webhook/NATS/Kafka/internal queue)","description":"## What\n\nOn every successful write (post-quorum), emit an event to configured sinks (plan §13.13):\n\n```json\n{\n \"mtask_id\": \"mtask-039x1\",\n \"index\": \"products\",\n \"operation\": \"add|update|delete\",\n \"primary_keys\": [\"sku_123\"],\n \"shard_ids\": [12, 47],\n \"settings_version\": 42,\n \"timestamp\": 1712345678901,\n \"document\": {\"...\"}\n}\n```\n\nSinks (parallel):\n- **webhook** — HTTP POST, batched (default 100 events or 1s), exponential backoff retries\n- **nats** — publish `miroir.cdc.{index}`\n- **kafka** — produce `miroir.cdc.{index}`\n- **internal queue** — `GET /_miroir/changes?since={cursor}&index={uid}` long-poll\n\nAt-least-once delivery; each event has a stable `event_id` for consumer-side dedup. Per-sink cursors in `cdc_cursors` table. Unreachable sinks buffer to tiered memory → overflow → drop.\n\n**`_miroir_origin` suppression**: internal writes (anti-entropy, reshard backfill, TTL sweep, ILM rollover) are tagged in-process (never persisted to doc body) and suppressed from CDC by default.\n\n## Why\n\nPlan §13.13: \"Downstream consumers — cache invalidators, audit loggers, recommendation trainers, analytics pipelines, secondary indexes — need to know when documents change.\"\n\n## Details\n\n**Config** (plan §13.13):\n```yaml\ncdc:\n enabled: true\n sinks: [...]\n buffer:\n primary: memory\n memory_bytes: 67108864 # 64 MiB\n overflow: redis\n redis_bytes: 1073741824 # 1 GiB per pod\n emit_ttl_deletes: false\n emit_internal_writes: false\n```\n\n**Buffer backend**: scratch container has no writable FS → default primary = memory. When `overflow: redis`, piggybacks on existing Redis requirement for HA (plan §14.4).\n\n**Scaling mode** (plan §14.6): per-pod publishers; `cdc_cursors` in task store serializes cursor advancement via compare-and-swap; each pod publishes its own shard of events.\n\n**Metrics** (plan §10): `miroir_cdc_events_published_total{sink,index}`, `miroir_cdc_lag_seconds{sink}`, `miroir_cdc_buffer_bytes{sink}`, `miroir_cdc_dropped_total{sink}`, `miroir_cdc_events_suppressed_total{origin}`.\n\n## Acceptance\n\n- [ ] Webhook sink receives one event per client write; zero events for anti-entropy repairs\n- [ ] NATS + Kafka dual sinks each receive the same event set\n- [ ] `GET /_miroir/changes?since=0&index=products` long-poll returns new events as they occur\n- [ ] Sink unreachable for 5 min → `miroir_cdc_buffer_bytes{sink}` grows; overflow to Redis when primary full; drops counted + alerted\n- [ ] `emit_ttl_deletes: true` reveals TTL-driven deletes in the stream","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:37:00.542902179Z","created_by":"coding","updated_at":"2026-04-24T03:52:38.016689125Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.13","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:38.016647285Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:37.996481535Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13","depends_on_id":"miroir-uhj.14","type":"blocks","created_at":"2026-04-18T21:38:33.305035025Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13","depends_on_id":"miroir-uhj.17","type":"blocks","created_at":"2026-04-18T21:38:33.333219791Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13","depends_on_id":"miroir-uhj.8","type":"blocks","created_at":"2026-04-18T21:38:33.268425307Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.13.1","title":"P5.13.a Webhook sink: batched POST + exponential backoff retries","description":"Plan §13.13 webhook sink. Batched POST to configured URL; default batch_size: 100 events or batch_flush_ms: 1000. Exponential backoff retries capped by retry_max_s: 3600. include_body opt-in per sink (default false for bandwidth). Per-sink cursor in cdc_cursors (Phase 3 table); advanced only on sink ACK.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:51:33.842369692Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.802206903Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.13.1","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.802177435Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13.1","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:36.787000737Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13.1","depends_on_id":"miroir-uhj.13.5","type":"blocks","created_at":"2026-04-18T21:52:43.106190717Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13.1","depends_on_id":"miroir-uhj.13.6","type":"blocks","created_at":"2026-04-18T21:52:42.998383150Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.13.2","title":"P5.13.b NATS sink: publish to subject prefix miroir.cdc.{index}","description":"Plan §13.13 NATS sink. Config: url (nats://nats.messaging.svc:4222), subject_prefix (miroir.cdc). For each event, PUB to miroir.cdc.{index}. Uses async-nats or similar. Subject-scoped filtering on consumer side.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-18T21:51:33.871723203Z","created_by":"coding","updated_at":"2026-04-24T03:52:38.855115134Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.13.2","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:38.855071727Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13.2","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:38.838846867Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13.2","depends_on_id":"miroir-uhj.13.6","type":"blocks","created_at":"2026-04-18T21:52:43.045450439Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.13.3","title":"P5.13.c Kafka sink: produce to topic miroir.cdc.{index}","description":"Plan §13.13 Kafka sink. Uses rdkafka. Partition key = primary_key (preserves per-key ordering). Delivery: at-least-once; event_id in each record's headers for consumer-side dedup.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-18T21:51:33.902914967Z","created_by":"coding","updated_at":"2026-04-24T03:52:38.803854731Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.13.3","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:38.803816288Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13.3","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:38.786477517Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13.3","depends_on_id":"miroir-uhj.13.6","type":"blocks","created_at":"2026-04-18T21:52:43.068140666Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.13.4","title":"P5.13.d Internal queue sink: GET /_miroir/changes long-poll","description":"Plan §13.13 internal queue sink. Long-poll endpoint: GET /_miroir/changes?since={cursor}&index={uid}. Cursor is monotonic per-index sequence. Returns bounded batch + next cursor. Long-poll timeout default 30s with empty response if nothing new. Intended for in-cluster subscribers that don't want NATS/Kafka/webhook infrastructure.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:51:33.923233600Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.751005877Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.13.4","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.750961746Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13.4","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:36.734540971Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13.4","depends_on_id":"miroir-uhj.13.6","type":"blocks","created_at":"2026-04-18T21:52:43.086328620Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.13.5","title":"P5.13.e Buffer backend: memory → overflow(redis/pvc/drop)","description":"Plan §13.13 buffer backend. Primary default: memory (64 MiB). Overflow default: redis (1 GiB per pod). Single-pod dev without Redis: opt-in primary: pvc or overflow: pvc — Helm renders miroir-pvc.yaml (§6 optional template). overflow: drop disables spill; events past watermark increment miroir_cdc_dropped_total immediately. §14.7 Redis memory budget: +1 GiB per pod when CDC overflow is on.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:51:33.938445052Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.702624210Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.13.5","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.702600186Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13.5","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:36.686887115Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.13.6","title":"P5.13.f Event suppression by _miroir_origin tag (internal writes)","description":"Plan §13.13 'CDC event suppression'. _miroir_origin tag is an internal orchestrator-side marker — NEVER stored on document, never returned to clients, never leaves the orchestrator process. Filter table: antientropy (§13.8, not emitted), reshard_backfill (§13.1 steps 2-3, not emitted), ttl_expire (§13.14, opt-in via cdc.emit_ttl_deletes), rollover (§13.17, not emitted), absent tag = client write (ALWAYS emitted). emit_internal_writes config enables debug mode where all internal writes appear in CDC. Suppression metric: miroir_cdc_events_suppressed_total{origin} counter.","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:51:33.961120513Z","created_by":"coding","updated_at":"2026-04-24T03:52:33.517536122Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.13.6","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:33.517505492Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.13.6","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:33.502520571Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.14","title":"P5.14 §13.14 Document TTL + automatic expiration","description":"## What\n\nAdd reserved field `_miroir_expires_at` (integer unix ms); background sweeper per-shard deletes expired docs via the shard-filter primitive (plan §13.14):\n\n```\nfor each owned shard s:\n POST /indexes/{uid}/documents/delete\n body: {\"filter\": \"_miroir_shard = {s} AND _miroir_expires_at <= {now_ms}\"}\n```\n\nSweep cadence per-index via `POST /_miroir/indexes/{uid}/ttl-policy`. Field stripped from responses like other `_miroir_*` fields (plan §5 reserved-fields table). `_miroir_expires_at` added to `filterableAttributes` automatically at index creation via §13.5 two-phase broadcast when TTL is enabled.\n\n## Why\n\nPlan §13.14: \"Session data, log entries, cache documents, GDPR records — all need expiration. Today: cron jobs with filter-delete. Often forgotten, often broken, sometimes OOM.\"\n\n## Details\n\n**Scaling mode** (plan §14.6): Mode A — each pod sweeps only its rendezvous-owned shards; no duplicate deletes.\n\n**Interaction with §13.8 anti-entropy** (plan §13.14 + §13.8 step 3):\n- TTL deletes fan out to ALL replicas in one quorum write (same as any other delete)\n- Anti-entropy treats expired docs as logically deleted regardless — \"highest updated_at wins\" is **suspended** for expired\n- Prevents zombie resurrection on every AE pass\n\n**Admin API**: `POST /_miroir/indexes/{uid}/ttl-policy` body `{\"sweep_interval_s\": N, \"max_deletes_per_sweep\": M, \"enabled\": bool}` (overrides `ttl.per_index_overrides` global).\n\n**Config**:\n```yaml\nttl:\n enabled: true\n sweep_interval_s: 300\n max_deletes_per_sweep: 10000\n expires_at_field: _miroir_expires_at\n per_index_overrides: {}\n```\n\n**Metrics**: `miroir_ttl_documents_expired_total{index}`, `miroir_ttl_sweep_duration_seconds{index}`, `miroir_ttl_pending_estimate{index}`.\n\n## Acceptance\n\n- [ ] Doc with `_miroir_expires_at = now - 1000` is gone after one sweep cycle\n- [ ] TTL sweep + late straggler write: zombie doc does NOT reappear after anti-entropy pass\n- [ ] CDC subscribers see TTL deletes only when `cdc.emit_ttl_deletes: true`\n- [ ] `_miroir_expires_at` stripped from search hits\n- [ ] 10k-doc sweep respects `max_deletes_per_sweep` (doesn't exceed)","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:37:00.567941804Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.963156745Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.14","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.963119074Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.14","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:37.945993240Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.15","title":"P5.15 §13.15 Tenant-to-replica-group affinity","description":"## What\n\nResolve tenant identity per request in one of three modes (plan §13.15):\n- **header** — `X-Miroir-Tenant` → `group = hash(tenant_id) % RG`\n- **api_key** — derive from inbound API key via `tenant_map` table\n- **explicit** — static map tenant → group_id; unknown tenants fall through to `fallback` routing\n\nWrites always fan out to all groups (consistency invariant preserved). Only **reads** honor affinity: tenant's queries pinned to tenant's group. Heavy tenant consumes only that group's capacity.\n\nOptional **dedicated groups** — mark groups as reserved for mapped tenants only; others share the pool.\n\n## Why\n\nPlan §13.15: \"Noisy-neighbor isolation in multi-tenant deployments. Without isolation, one tenant's 10 kQPS spike degrades every other tenant's queries. Without Miroir, this forces operators to run fully separate clusters per tenant.\"\n\n## Details\n\n**Scaling mode**: stateless per-request; tenant map LRU is per-pod.\n\n**Memory**: `tenant_map` LRU ~20 MB (plan §14.2 only when `mode: api_key`).\n\n**Interaction with §13.6 session pinning**: session pin wins on conflict (plan §13.11 Interaction paragraph + metric `miroir_tenant_session_pin_override_total`).\n\n**Interaction with §13.3 adaptive selection**: tenant affinity narrows the group; adaptive selection chooses within.\n\n**Config** (plan §13.15):\n```yaml\ntenant_affinity:\n enabled: true\n mode: header\n header_name: X-Miroir-Tenant\n fallback: hash # hash | random | reject\n static_map: {enterprise-co: 0, startup-inc: 1}\n dedicated_groups: [0] # group 0 reserved for mapped tenants only\n```\n\n**Metrics**: `miroir_tenant_queries_total{tenant, group}`, `miroir_tenant_pinned_groups{tenant}`, `miroir_tenant_fallback_total{reason}`.\n\n## Acceptance\n\n- [ ] Tenant-A queries pin to group 0 consistently; tenant-B pins to group 1\n- [ ] Tenant-A 10kQPS burst does NOT raise tenant-B latency (measured in a chaos test)\n- [ ] Writes from tenant-A still fan out to ALL groups (durability invariant)\n- [ ] Unknown tenant with `fallback: reject` → 401 / 400 per policy\n- [ ] Dedicated groups: non-mapped tenant cannot be routed to group 0","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:37:00.588242214Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.908249455Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.15","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.908204067Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.15","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:37.892034592Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.16","title":"P5.16 §13.16 Traffic shadow / teeing to a staging cluster","description":"## What\n\nAsync-shadow a configurable fraction of incoming requests to another Miroir or standalone Meilisearch (plan §13.16):\n\n```\nclient ──→ Miroir ──→ primary cluster ──→ response to client (synchronous)\n └──→ shadow cluster ──→ async diff worker\n ↓\n /_miroir/shadow/diff stream\n prometheus histograms\n```\n\nDiff worker compares responses:\n- hit set symmetric difference\n- ranking-order Kendall τ\n- latency Δ\n- error rate (shadow vs. primary)\n\nResults to in-memory ring buffer (queryable at `/_miroir/shadow/diff`) + summarized in Prometheus histograms.\n\n## Why\n\nPlan §13.16: \"Every settings change, ranking-rule tweak, Meilisearch upgrade, or Miroir config change carries risk. Validating against real production traffic is the only reliable way — but production is the scariest place to experiment.\"\n\n## Details\n\n**Writes are NEVER shadowed** — config enforces `operations: [search, multi_search, explain]`.\n\n**Config** (plan §13.16):\n```yaml\nshadow:\n enabled: true\n targets:\n - name: staging\n url: http://miroir-staging.search.svc:7700\n api_key_env: SHADOW_API_KEY\n sample_rate: 0.05\n operations: [search, multi_search, explain]\n diff_buffer_size: 10000\n max_shadow_latency_ms: 5000\n```\n\n**Scaling mode**: stateless per-request; each pod independently decides via local RNG whether to shadow.\n\n**Ring buffer**: plan §4 task store explicitly **does not** persist shadow diffs — in-memory only.\n\n**Client isolation**: shadow failures never impact primary latency; worst case shadow is canceled via `max_shadow_latency_ms` budget.\n\n**Metrics**: `miroir_shadow_diff_total{kind=hits|ranking|latency|error}`, `miroir_shadow_kendall_tau` histogram, `miroir_shadow_latency_delta_seconds` histogram, `miroir_shadow_errors_total{target, side}`.\n\n**Admin API**: `GET /_miroir/shadow/diff?target={name}&limit=N&since_id=X&kind={hits,ranking,latency,error}`.\n\n## Acceptance\n\n- [ ] 5% sampled — ~50/1000 queries go to shadow (verified in test)\n- [ ] Shadow cluster down → 0 impact on primary latency or error rate\n- [ ] Ring buffer reports divergences; buffer size bounded; oldest evicted when full\n- [ ] Writes never appear in shadow target's logs (operations filter enforced)","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:37:00.605599542Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.853765144Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.16","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.853724446Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.16","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:37.835017336Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.17","title":"P5.17 §13.17 Rolling time-series indexes (ILM rollover)","description":"## What\n\nAttach a rollover policy to an alias (plan §13.17). A daily leader-coordinated job evaluates every policy:\n1. If any trigger (max_docs, max_age, max_size_gb) fires, create `logs-20260419` using template (index + settings via §13.5)\n2. Atomic alias flip: `logs` (write alias) → new index (§13.7). Old index retained but no new writes.\n3. `logs-search` read alias is a **multi-target alias** pointing at last N indexes; reads fan out via §13.11 multi-search, merge by `_rankingScore`\n4. Indexes older than `retention.keep_indexes` deleted\n\nEvery step uses existing public API.\n\n## Why\n\nPlan §13.17: \"Log, event, metric, and telemetry search is the largest single search-workload segment, and it has a distinct shape: heavy writes, read-by-recency, delete-oldest-first. Elasticsearch dominates that market largely because of its ILM. Meilisearch CE has none.\"\n\n## Details\n\n**Scaling mode** (plan §14.6): Mode B — serialized alias flips + index create/delete; exactly one pod runs the daily evaluator.\n\n**Multi-target alias constraint** (§13.7): only ILM may create/modify/delete `read_alias`; operator `PUT` on a multi-target alias → 409 `miroir_multi_alias_not_writable`.\n\n**CDC suppression**: rollover copy writes are tagged `_miroir_origin: rollover` and suppressed from CDC by default.\n\n**Safety lock**: `safety_lock_older_than_days` (default 7) refuses to delete indexes newer than that — prevents foot-gun.\n\n**Config**:\n```yaml\nilm:\n enabled: true\n check_interval_s: 3600\n safety_lock_older_than_days: 7\n max_rollovers_per_check: 10\n\nrollover_policies:\n - name: logs-ilm\n write_alias: logs\n read_alias: logs-search\n pattern: \"logs-{YYYY-MM-DD}\"\n rollover_triggers:\n max_docs: 10000000\n max_age: \"7d\"\n max_size_gb: 50\n retention:\n keep_indexes: 30\n index_template:\n primary_key: event_id\n settings_ref: logs-settings\n```\n\n**Metrics**: `miroir_rollover_events_total{policy}`, `miroir_rollover_active_indexes{alias}`, `miroir_rollover_documents_expired_total{policy}`, `miroir_rollover_last_action_seconds{policy}`.\n\n## Acceptance\n\n- [ ] `max_docs` trigger fires: new index created; `logs` alias flipped; old index still readable via `logs-search` multi-alias\n- [ ] `keep_indexes: 30`: 31st-oldest index deleted; queries against `logs-search` no longer return its hits\n- [ ] `safety_lock_older_than_days: 7` blocks deletion attempts on 3-day-old indexes with a clear log line\n- [ ] Operator `PUT` on `logs-search` → 409 `miroir_multi_alias_not_writable`","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:37:00.631467886Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.799044686Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.17","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.799007347Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.17","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:37.782275693Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.17","depends_on_id":"miroir-uhj.7","type":"blocks","created_at":"2026-04-18T21:38:33.361849953Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.18","title":"P5.18 §13.18 Synthetic canary queries + golden assertions","description":"## What\n\nRegister canaries (predefined query + expected assertions); background worker runs each on its schedule; assertion failures fire metrics + alerts (plan §13.18):\n\n```yaml\ncanaries:\n - name: product_inception\n index: products\n interval_s: 60\n query: {q: \"inception\", limit: 10}\n assertions:\n - {type: top_hit_id, value: \"movie_inception\"}\n - {type: top_k_contains, k: 3, ids: [...]}\n - {type: min_hits, value: 5}\n - {type: max_p95_ms, value: 200}\n - {type: settings_version_at_least, value: 42}\n - {type: must_not_contain_id, ids: [...]}\n```\n\nAdmin API:\n- `POST /_miroir/canaries` — create/modify\n- `GET /_miroir/canaries/status` — last N runs, pass/fail counts, last-failure detail\n- `POST /_miroir/canaries/capture` — record next M production queries + responses as golden pairs\n\n## Why\n\nPlan §13.18: \"The highest-risk failure mode in search is not a node crash (those are detected by metrics) — it is **silent relevance regression**. A settings change, a synonym typo, a stop-word edit, or a ranking-rule reorder can quietly ruin search quality while every metric looks fine. Operators discover it when users complain.\"\n\n## Details\n\n**Scaling mode** (plan §14.6): Mode A — each canary ID rendezvous-owned by exactly one pod per interval; no duplicate canary runs.\n\n**Run history bound**: `canary_runner.run_history_per_canary` (default 100); older rows pruned on insert.\n\n**CDC integration**: `canary_runner.emit_results_to_cdc: true` publishes canary pass/fail as CDC events for downstream alerting pipelines.\n\n**Seeding**: `POST /_miroir/canaries/capture` records next M production queries + responses; operators promote good pairs via Admin UI (§13.19 canary heatmap).\n\n**Metrics**: `miroir_canary_runs_total{canary, result}`, `miroir_canary_latency_ms{canary}`, `miroir_canary_assertion_failures_total{canary, assertion_type}`.\n\n## Acceptance\n\n- [ ] Create canary → runs on schedule; pass/fail history accumulates\n- [ ] Assertion failure → metric + log line + optional alert; the detail includes the actual observed value\n- [ ] Capture flow: submit 10 production queries → 10 canaries saved → manually promote via `POST /_miroir/canaries`\n- [ ] Mode A: 3 pods, each canary runs exactly once per interval cluster-wide","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:37:00.668372717Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.747338793Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.18","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.747297453Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.18","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:37.728047525Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.19","title":"P5.19 §13.19 Admin Web UI (embedded SPA via rust-embed)","description":"## What\n\nSingle-page admin app embedded in the Miroir binary via `rust-embed`. Served at `/_miroir/admin`. Auth: admin API key (bearer or `X-Admin-Key`) or session cookie after login.\n\n## Sections (plan §13.19)\n\n- Overview — cluster health, degraded shards, active rebalances/reshards, recent canary failures, CDC backlog\n- Topology — node health table, shard coverage map, group membership, rebalance/reshard progress\n- Indexes — list/create/delete; settings viewer/editor with **2PC preview** showing diff + fingerprint (§13.5)\n- Aliases — list/create/flip/delete, history timeline (§13.7)\n- Documents — paginated browser; filter builder; CSV/NDJSON drag-drop → §13.9 streaming import\n- Query Sandbox — filter/sort/facet builders; instant-run with per-shard latency; one-click §13.20 explain; §13.16 shadow diff\n- Tasks — active + recent; per-node breakdown; retry/cancel\n- Canaries — list/create/edit/disable; pass-fail heatmap; seed-from-traffic (§13.18)\n- Shadow Diff — live stream + aggregated summary (§13.16)\n- CDC Inspector — live tail with filter (§13.13)\n- Metrics — Grafana iframe OR direct Prometheus panels\n- Settings — edit Miroir config with reload-hint annotations\n\n## Why\n\nPlan §13.19: \"The Meilisearch ecosystem lacks a built-in control panel for CE users. Every operator eventually writes their own bespoke tooling. Miroir ships a great one.\"\n\n## Design Philosophy (plan §13.19 full paragraph)\n\n- **Beautiful and functional**: content-first, minimal chrome, generous whitespace, single sans-serif (system-ui → Inter)\n- **Responsive**: mobile < 640px single-col + hamburger; tablet two-col; desktop three-pane + ⌘K palette + `/` focus + arrow-nav; max-width 1440px\n- **Accessibility**: WCAG 2.2 AA, keyboard nav, ARIA roles, focus rings, screen-reader live regions, `prefers-reduced-motion`\n- **Performance**: ≤ 100 KB gzipped total; Preact + vanilla CSS (no Tailwind runtime); code-split; SSE for task progress/canary/CDC\n- **Trust & safety**: destructive actions require confirmation modal that echoes the target name the user must retype; immutable on-screen activity log with operator identity from admin-key label\n\n## Config\n\n```yaml\nadmin_ui:\n enabled: true\n path: /_miroir/admin\n auth: key\n session_ttl_s: 3600\n read_only_mode: false\n allowed_origins: [same-origin]\n cors_allowed_origins: []\n csp_overrides: {script_src: [], img_src: [], connect_src: []}\n theme: {accent_color: \"#2563eb\", default_mode: auto}\n features: {sandbox: true, shadow_viewer: true, cdc_inspector: true}\n```\n\n**Session cookie seal**: `ADMIN_SESSION_SEAL_KEY` (§9) — HMAC-SHA256 + XChaCha20-Poly1305. Must be shared across multi-pod.\n\n**CSRF** (§9): `X-CSRF-Token` double-submit on cookie-authenticated state-changing requests; bearer/X-Admin-Key bypass CSRF.\n\n**Login endpoints**: `POST /_miroir/admin/login`, `POST /_miroir/admin/logout`. Rate-limited (`miroir:ratelimit:adminlogin:`, exponential backoff).\n\n**Logout propagation**: `admin_sessions.revoked` flipped; `miroir:admin_session:revoked` Pub/Sub notifies peers for instant invalidation.\n\n## Metrics\n\n`miroir_admin_ui_sessions_total`, `miroir_admin_ui_action_total{action}`, `miroir_admin_ui_destructive_action_total{action}`.\n\n## Acceptance\n\n- [ ] SPA loads in < 2s on 3G-simulated network; bundle ≤ 100 KB gzipped\n- [ ] Desktop + tablet + mobile layouts pass WCAG 2.2 AA axe scans\n- [ ] Destructive action (delete index) requires typing the UID to confirm\n- [ ] Login → action → logout on pod-A; replay cookie on pod-B → 401\n- [ ] Session cookie seal fails verification when `ADMIN_SESSION_SEAL_KEY` differs across pods (documented + tested failure)\n- [ ] Dark mode toggle persists across reload","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:38:21.454463397Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.695232712Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.19","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.695209085Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.19","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:37.680257440Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.19","depends_on_id":"miroir-uhj.13","type":"blocks","created_at":"2026-04-18T21:38:33.414990943Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.19","depends_on_id":"miroir-uhj.16","type":"blocks","created_at":"2026-04-18T21:38:33.442504916Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.19","depends_on_id":"miroir-uhj.20","type":"blocks","created_at":"2026-04-18T21:38:33.463577377Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.19","depends_on_id":"miroir-uhj.5","type":"blocks","created_at":"2026-04-18T21:38:33.380588500Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.19.1","title":"P5.19.a Overview + Topology sections (cluster health, node table, shard map)","description":"Plan §13.19 Admin UI sections. Overview: cluster health summary, degraded shard count, active rebalances/reshards, recent canary failures, CDC backlog. Topology: node health table, shard coverage map (heatmap or grid), group membership, rebalance/reshard progress bars. Data sourced from GET /_miroir/topology + GET /_miroir/shards + GET /_miroir/rebalance/status. SSE updates for live status.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:51:56.126209116Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.650913134Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.19.1","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.650870219Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.19.1","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:36.633307775Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.19.2","title":"P5.19.b Indexes + Aliases sections + 2PC settings preview","description":"Plan §13.19. Indexes: list/create/delete; settings viewer/editor with LIVE 2PC preview showing diff + fingerprint BEFORE commit (§13.5 integration). Aliases: list/create/flip/delete with history timeline (§13.7). 2PC preview is the critical feature — shows operators what the §13.5 propose/verify/commit flow will do before they click Apply.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:51:56.151262934Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.598295110Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.19.2","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.598253845Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.19.2","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:36.578044203Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.19.3","title":"P5.19.c Documents + Query Sandbox + Tasks sections","description":"Plan §13.19. Documents: paginated browser per index; filter builder; CSV/NDJSON drag-and-drop triggers §13.9 streaming import. Query Sandbox: filter/sort/facet builders; instant-run with per-shard latency breakdown; one-click §13.20 explain; side-by-side diff vs. §13.16 shadow. Tasks: active + recent tasks; per-node breakdown; retry/cancel where applicable.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:51:56.192971889Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.542906521Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.19.3","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.542863439Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.19.3","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:36.526395621Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.19.3.1","title":"P5.19.3.a Admin UI: Documents section (browser, filter, CSV/NDJSON import)","description":"Plan §13.19 Documents section. Paginated document browser per index; filter builder with UI hints; drag-and-drop CSV/NDJSON triggers §13.9 streaming import via POST /_miroir/dumps/import. Show per-row controls (view/edit/delete).","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:40:14.934881276Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.446734888Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.19.3.1","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:35.446710700Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.19.3.1","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:35.427761008Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.19.3.2","title":"P5.19.3.b Admin UI: Query Sandbox with shard-level latency + explain integration","description":"Plan §13.19 Query Sandbox. Filter/sort/facet-request builders. Instant-run button with per-shard latency breakdown visible. One-click §13.20 explain invocation rendering the plan visually (shard-to-node arrows, color-coded warnings). Side-by-side diff vs §13.16 shadow when enabled.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:40:14.971918287Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.397499582Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.19.3.2","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:35.397474940Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.19.3.2","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:35.378821875Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.19.3.3","title":"P5.19.3.c Admin UI: Tasks section (active+recent, per-node breakdown, retry/cancel)","description":"Plan §13.19 Tasks section. Table of active + recent tasks (mtask_id, index, status, duration). Expand a row → per-node task breakdown. Retry/cancel controls where applicable (delegates to Meilisearch cancel API).","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:40:15.004848744Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.344078510Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.19.3.3","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:35.344040412Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.19.3.3","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:35.326802490Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.19.4","title":"P5.19.d Canaries + Shadow Diff + CDC Inspector + Metrics + Settings sections","description":"Plan §13.19. Canaries: list/create/edit/disable; pass-fail heatmap over time; seed-from-traffic flow (§13.18). Shadow Diff: live stream + aggregated summary from §13.16. CDC Inspector: subscribe to live tail of §13.13 with filter by index/operation. Metrics: Grafana iframe OR direct Prometheus panel render. Settings: read/edit Miroir config with restart hints for runtime-vs-reload knobs.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:51:56.225623090Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.490309295Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.19.4","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.490262002Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.19.4","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:36.473343737Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.19.5","title":"P5.19.e Login/logout + CSRF + session seal + rate limit + responsive design","description":"Plan §13.19 Admin UI non-section concerns: login form → POST /_miroir/admin/login (session cookie via §9 ADMIN_SESSION_SEAL_KEY). Logout → POST /_miroir/admin/logout (session revoked, Redis Pub/Sub propagation). CSRF double-submit via X-CSRF-Token on state-changing requests. Login rate limit 10/minute per IP + exponential backoff (§10 P10.7). Responsive breakpoints: mobile <640, tablet 640-1024, desktop ≥1024, max-width 1440. WCAG 2.2 AA. Bundle ≤ 100 KB gzipped. Destructive-action confirm modal echoing target name.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:51:56.250675239Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.441916301Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.19.5","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.441892513Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.19.5","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:36.426420950Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.2","title":"P5.2 §13.2 Hedged requests for tail-latency mitigation","description":"## What\n\nImplement tail-latency hedging for reads (plan §13.2):\n- Each in-flight node request starts a hedge timer at that node's rolling p95 latency (measured by §13.3 EWMA)\n- If timer fires, issue duplicate request to another replica (intra-group alternate, or cross-group if policy permits)\n- `tokio::select!` races both; loser's future is dropped (aborts Miroir-side HTTP connection)\n\nApplies to reads ONLY — `/search`, `/indexes/{uid}/documents`, `/indexes/{uid}/documents/{id}`. Writes are never hedged (duplicates produce extra Meilisearch tasks + potential auto-ID dupes).\n\n## Why\n\nPlan §13.2: \"A scatter-gather query's latency is bounded by the slowest responding shard. A single GC-paused or disk-throttled node poisons p99 across the whole fleet.\" Hedging trades a small cost (occasional extra node request) for a large win (tail latency roughly halved on skewed workloads).\n\n## Details\n\n**Config** (plan §13.2):\n```yaml\nhedging:\n enabled: true\n p95_trigger_multiplier: 1.2\n min_trigger_ms: 15\n max_hedges_per_query: 2\n cross_group_fallback: true\n```\n\n**Idempotency**: reads are side-effect-free, so no cache needed. Just race.\n\n**Scaling mode**: stateless per-request; each pod hedges its own requests independently.\n\n**Interaction with §13.3**: hedging reads the per-node p95 from the same EWMA registry §13.3 writes to.\n\n## Acceptance\n\n- [ ] Chaos test: `tc netem delay 500ms` on one of 3 nodes; hedged fan-out avoids the slow node via the other 2 replicas; p95 close to healthy-cluster p95\n- [ ] Write path verified NOT to hedge (no duplicate node task IDs under any scenario)\n- [ ] `miroir_hedge_fired_total{outcome=winner|loser}` counters tick in test runs\n- [ ] `max_hedges_per_query` cap prevents thundering herd under widespread node degradation","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:33:36.758491853Z","created_by":"coding","updated_at":"2026-04-24T03:52:38.306961914Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.2","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:38.306929502Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.2","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:38.290083749Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.2","depends_on_id":"miroir-uhj.3","type":"blocks","created_at":"2026-04-18T21:38:33.151102819Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.20","title":"P5.20 §13.20 Query Explain API","description":"## What\n\n`POST /indexes/{uid}/explain` — same body as `/search`, returns the orchestrator's resolved plan without executing (plan §13.20). `?execute=true` also runs the plan and returns the real result.\n\n## Plan shape (plan §13.20 example):\n\n```json\n{\n \"resolved_uid\": \"products_v4\",\n \"plan\": {\n \"alias_resolution\": {\"from\": \"products\", \"to\": \"products_v4\", \"version\": 7},\n \"narrowed\": true,\n \"narrowing_reason\": \"pk filter: product_id IN [3 values]\",\n \"target_shards\": [12, 47, 53],\n \"chosen_group\": {\"id\": 0, \"reason\": \"lowest EWMA score (38 ms vs. group 1 at 52 ms)\"},\n \"target_nodes\": {\"12\": \"meili-1\", \"47\": \"meili-1\", \"53\": \"meili-2\"},\n \"hedging_armed\": true,\n \"hedge_trigger_ms\": 22,\n \"coalescing_eligible\": true,\n \"cache_candidate\": false,\n \"tenant_affinity_pinned\": null,\n \"estimated_p95_ms\": 18,\n \"settings_version\": 42\n },\n \"warnings\": [\"filter references `category` but `category` is not in filterableAttributes — full table scan\", ...]\n}\n```\n\nWarnings cover: unfilterable attrs in filters, very large `offset + limit`, unbounded wildcards, settings drift, tenant affinity mismatch, narrowing-not-possible explanation.\n\n## Why\n\nPlan §13.20: \"'Why is this query slow?' is the #1 operational question. Miroir already **knows** the full plan — it should return it on request.\"\n\n## Details\n\n**Auth scope**:\n- master_key → warnings filtered to remove operator-only signals (drift, tenant mismatch, min-settings-floor)\n- admin_key → all warnings surface unredacted\n\n**Mid-broadcast behavior** (plan §13.20): `plan.settings_version` = last committed; `plan.broadcast_pending: true` + `commit in ~2.4s` when 2PC in flight. `?execute=true` during 2PC executes against last committed; `X-Miroir-Settings-Pending: true` header.\n\n**Admin UI integration**: Query Sandbox one-click Explain; output rendered with shard-to-node arrows + color-coded warnings.\n\n**Config**:\n```yaml\nexplain:\n enabled: true\n max_warnings: 20\n allow_execute_parameter: true\n```\n\n**Metrics**: `miroir_explain_requests_total`, `miroir_explain_warnings_total{warning_type}`, `miroir_explain_execute_total`.\n\n## Acceptance\n\n- [ ] Plan for a PK-narrowed query shows `narrowed: true` + reduced `target_shards`\n- [ ] Warnings list populated for known anti-patterns (unfilterable attribute, offset+limit > 10k)\n- [ ] `?execute=true` returns both plan AND result in one call\n- [ ] master_key vs admin_key: warnings filtered differently; plan shape identical","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:38:21.488657531Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.647002327Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.20","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.646961077Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.20","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:37.630274623Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.21","title":"P5.21 §13.21 End-user Search UI + JWT brokering + scoped-key rotation","description":"## What\n\nPublic end-user search SPA embedded via `rust-embed` at `/ui/search/{index}` (plan §13.21). Per-index config via `POST /_miroir/ui/search/{index}/config`.\n\n**Capabilities**: instant-search (150ms debounce + §13.10 coalescing), combined multi-search per keystroke (§13.11), URL state (bookmarkable), keyboard nav, highlighting, typo-tolerance UI, empty state + \"did you mean,\" pagination, dark mode, i18n via `GET /_miroir/ui/search/locale/{lang}.json`.\n\n**Embeddable modes**: iframe, web component (``), headless (no chrome).\n\n## Auth Model — Two-Layer Credential Chain\n\n1. **Scoped Meilisearch key** (orchestrator-held, rotated). Created per-index with `actions: [\"search\"]` scope. Hard expiration `scoped_key_max_age_days` (60d); auto-rotated `scoped_key_rotate_before_expiry_days` (30d) before expiry.\n\n **Rotation coordination**: Redis hash `miroir:search_ui_scoped_key:` {primary_uid, previous_uid, rotated_at, generation}; leader lease `search_ui_key_rotation:`; per-pod beacon `miroir:search_ui_scoped_key_observed::` with 60s TTL. Revocation safety gate: all live peers must report new generation before leader `DELETE /keys/{old}`. Drain wait `scoped_key_rotation_drain_s` (120s).\n\n2. **Short-lived JWT** (browser-held, 15-min default). `GET /_miroir/ui/search/{index}/session` mints a JWT signed by `SEARCH_UI_JWT_SECRET`. Claims: `iss=miroir`, `sub=search-ui-session`, `idx=`, `scope=[search, multi_search, beacon]`, `exp`, `iat`, `kid`, optional `injected_filter`. SPA then calls `/indexes/{uid}/search` with `Authorization: Bearer `; orchestrator validates + **substitutes scoped key** before forwarding.\n\n **Scope + idx check** (defense-in-depth): validate on every request before any node call; (method, path) must match action in scope AND `idx` must equal target index. Else `miroir_jwt_scope_denied` (403).\n\n3. **Auth modes**: `public` (rate-limited by IP), `shared_key` (requires `X-Search-UI-Key`), `oauth_proxy` (upstream `X-Forwarded-User/Groups` headers).\n\n4. **Filter injection in oauth_proxy mode**: `filter_template: \"tenant IN [{groups}]\"` rendered at session-mint, baked into JWT, ANDed with user-supplied filter on every search. Enforces per-user access control.\n\n## Why\n\nPlan §13.21: \"For many use cases — internal tools, knowledge bases, docs search, catalog browsers, demos, MVPs — a great default UI is all that is needed. Miroir ships one.\"\n\n## Analytics\n\n`search_ui.analytics.enabled: true` → SPA emits beacons on result click + search completion via `POST /_miroir/ui/search/{index}/beacon`. Idempotent via client-generated `event_id`.\n\n## Config (plan §13.21)\n\n```yaml\nsearch_ui:\n enabled: true\n path: /ui/search\n widget_script_enabled: true\n embeddable: true\n auth:\n mode: public # public | shared_key | oauth_proxy\n session_ttl_s: 900\n session_rate_limit: \"10/minute\"\n jwt_secret_env: SEARCH_UI_JWT_SECRET\n oauth_proxy: {...filter_template...}\n allowed_origins: [\"*\"]\n scoped_key_max_age_days: 60\n scoped_key_rotate_before_expiry_days: 30\n scoped_key_rotation_drain_s: 120\n rate_limit:\n per_ip: \"60/minute\"\n backend: redis\n cors_allowed_origins: []\n csp: \"default-src 'self'; img-src 'self' https:; style-src 'self' 'unsafe-inline'\"\n analytics: {enabled: false, sink: cdc}\n```\n\n## Design philosophy (plan §13.21)\n\n- Preact + vanilla CSS; ≤ 60 KB gzipped\n- Responsive: mobile bottom-sheet facet drawer, tablet 2-col, desktop 3-col, large-desktop clamp 1440px\n- WCAG 2.2 AA; semantic HTML landmarks; ARIA live region for result counts; Lighthouse perf ≥ 95 on 4G mid-Android\n- SSR-free\n\n## Acceptance\n\n- [ ] SPA loads < 2s on 4G Android; bundle ≤ 60 KB gzipped\n- [ ] JWT mint + search + client rotation: zero user impact\n- [ ] Scoped key rotation: 30d before expiry auto-triggers; drain-and-revoke completes without rejecting any in-flight request\n- [ ] `oauth_proxy` + filter injection: tenant A cannot retrieve tenant B's docs via a crafted query\n- [ ] Analytics beacon: `event_id` idempotency prevents double-counting on browser retry\n- [ ] `values.schema.json` rejects `scoped_key_rotate_before_expiry_days >= scoped_key_max_age_days`","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:38:21.535554827Z","created_by":"coding","updated_at":"2026-04-24T03:52:37.598428714Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.21","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:37.598404960Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:37.582663001Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21","depends_on_id":"miroir-uhj.10","type":"blocks","created_at":"2026-04-18T21:38:33.528690212Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21","depends_on_id":"miroir-uhj.11","type":"blocks","created_at":"2026-04-18T21:38:33.499500618Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21","depends_on_id":"miroir-uhj.6","type":"blocks","created_at":"2026-04-18T21:38:33.553874039Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.21.1","title":"P5.21.a Scoped Meilisearch key management + rotation (§9 + §13.21 auth layer 1)","description":"Plan §13.21 auth model layer 1. When search UI first enabled for an index, orchestrator creates scoped search-only key on every Meilisearch node via POST /keys with actions: [search], indexes scoped. Hard expiration scoped_key_max_age_days (60d default). Auto-rotated scoped_key_rotate_before_expiry_days (30d default). See P10.5 for the rotation coordination (Redis hash + leader lease + per-pod beacon + revocation safety gate + drain). This subtask implements the 'key lifecycle' side — creation, storage, retrieval from Redis hash at request time.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:52:33.150398495Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.394716067Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.21.1","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.394690325Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21.1","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:36.378678223Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.21.2","title":"P5.21.b JWT session minting + scope/idx validation (§13.21 auth layer 2)","description":"Plan §13.21 auth model layer 2. GET /_miroir/ui/search/{index}/session returns {token, expires_at, index, rate_limit}. Token is JWT signed by SEARCH_UI_JWT_SECRET (§9 rotation). TTL default 15m. Claims: iss=miroir, sub=search-ui-session, idx=, scope=[search, multi_search, beacon], exp, iat, kid. On subsequent /indexes/{uid}/search: validate JWT → orchestrator SUBSTITUTES scoped Meilisearch key before forwarding to nodes (scoped key never leaves orchestrator). Defense-in-depth: orchestrator validates (method,path) against scope AND idx claim against target index BEFORE any node call. Mismatch: miroir_jwt_scope_denied (403).","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:52:33.173618256Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.345952680Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.21.2","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.345923150Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21.2","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:36.327076851Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21.2","depends_on_id":"miroir-uhj.21.1","type":"blocks","created_at":"2026-04-18T21:52:43.125423443Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.21.3","title":"P5.21.c Auth modes: public / shared_key / oauth_proxy + filter injection","description":"Plan §13.21 auth modes. public: session endpoint unauthenticated but IP rate-limited (default 10/minute). shared_key: X-Search-UI-Key header required (from search_ui.auth.shared_key_env). oauth_proxy: expects upstream headers (X-Forwarded-User, X-Forwarded-Groups) injected by oauth2-proxy. In oauth_proxy mode, if filter_template non-null (e.g., 'tenant IN [{groups}]'), the rendered filter is baked into the JWT injected_filter claim and ANDed with any user-supplied filter on every search — enforces per-user access control. values.schema.json rejects scoped_key_rotate_before >= scoped_key_max_age.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:52:33.192922898Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.296270375Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.21.3","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.296245475Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21.3","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:36.277741844Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21.3","depends_on_id":"miroir-uhj.21.2","type":"blocks","created_at":"2026-04-18T21:52:43.142891447Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.21.4","title":"P5.21.d SPA: instant-search, facets, URL state, keyboard nav, i18n","description":"Plan §13.21 SPA capabilities. Instant-search 150ms debounce + §13.10 query coalescing. Combined multi-search per keystroke via §13.11 (results + all facets in one call). URL state encodes q+filters+sort+page (bookmarkable). Keyboard nav: / to focus, arrows to move, Enter to open, Esc to clear. Highlighting via _formatted. Typo tolerance UI + 'did you mean' on zero hits. Empty state with popular queries (from §13.18 canaries). Dark mode via prefers-color-scheme + manual toggle. i18n via GET /_miroir/ui/search/locale/{lang}.json. Bundle ≤ 60 KB gzipped. Preact + vanilla CSS. Responsive: mobile bottom-sheet, tablet 2-col, desktop 3-col, max-width 1440.","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:52:33.208231343Z","created_by":"coding","updated_at":"2026-04-24T03:52:36.243345814Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.21.4","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:36.243301749Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21.4","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:36.226675921Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21.4","depends_on_id":"miroir-uhj.21.3","type":"blocks","created_at":"2026-04-18T21:52:43.170559074Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.21.4.1","title":"P5.21.4.a Search UI SPA: instant-search + 150ms debounce + §13.10 coalescing","description":"Plan §13.21. Instant-search with 150ms debounce; every keystroke issues a §13.11 multi-search covering {results, all-facets}. §13.10 query-coalescing dedups concurrent identical keystrokes across users. Cancel in-flight request on new keystroke (AbortController).","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:40:48.699884138Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.296643833Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.21.4.1","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:35.296601396Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21.4.1","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:35.275870335Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.21.4.2","title":"P5.21.4.b Search UI SPA: facets (checkbox/range/star) + URL state","description":"Plan §13.21 facet config schema. Render per-index facet spec (checkbox list, range slider, rating stars). Every query + filter + sort + page encoded in URL for bookmarkable results. Back/forward history works (pushState/popstate).","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:40:48.732981525Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.235645104Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.21.4.2","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:35.235605833Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21.4.2","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:35.218544451Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.21.4.3","title":"P5.21.4.c Search UI SPA: keyboard nav + highlighting + typo tolerance + empty state","description":"Plan §13.21. / to focus, arrows to move, Enter to open, Esc to clear. Meilisearch _formatted for highlighting. 'Did you mean?' suggestions on zero hits. Empty state with popular queries (from §13.18 canaries or operator-configured suggestions).","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-21T12:40:48.763809756Z","created_by":"coding","updated_at":"2026-04-24T03:52:35.186346737Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.21.4.3","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:35.186310066Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21.4.3","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:35.167734350Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.21.4.4","title":"P5.21.4.d Search UI SPA: dark mode + i18n + infinite scroll/pagination","description":"Plan §13.21. prefers-color-scheme + manual toggle stored in localStorage. i18n via GET /_miroir/ui/search/locale/{lang}.json (cached long max-age). Infinite scroll on mobile, classic pagination on desktop (configurable). per_page_default + per_page_options.","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-21T12:40:48.792843810Z","created_by":"coding","updated_at":"2026-04-24T03:52:38.550160591Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5","ui"],"dependencies":[{"issue_id":"miroir-uhj.21.4.4","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-24T03:52:38.550132947Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-uhj.21.4.4","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-24T03:52:38.534507622Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-uhj.21.5","title":"P5.21.e Embeddable modes (iframe, web component, headless) + custom templates","description":"Plan §13.21 embeddable modes. Iframe: