From 6667949ec6a0dd783d308da9c922720e28c7545f Mon Sep 17 00:00:00 2001 From: jedarden Date: Sun, 19 Apr 2026 02:42:47 -0400 Subject: [PATCH] P12.OP4 follow-up: create global-IDF preflight bead (miroir-yio) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RRF validation confirmed τ=0.14 against ground truth — RRF alone is insufficient for cross-shard comparability. Follow-up bead miroir-yio tracks the dfs_query_then_fetch implementation. Co-Authored-By: Claude Opus 4.7 --- .beads/issues.jsonl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4309d6d..7185e1b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -30,7 +30,7 @@ {"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":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:42:04.602737281Z","created_by":"coding","updated_at":"2026-04-18T21:42:04.602737281Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-7"],"dependencies":[{"issue_id":"miroir-afh.5","depends_on_id":"miroir-afh","type":"parent-child","created_at":"2026-04-18T21:42:04.602737281Z","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":"open","priority":2,"issue_type":"task","created_at":"2026-04-18T21:42:04.629100946Z","created_by":"coding","updated_at":"2026-04-18T21:42:04.629100946Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["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":"in_progress","priority":0,"issue_type":"epic","assignee":"alpha","created_at":"2026-04-18T21:18:33.134146061Z","created_by":"coding","updated_at":"2026-04-19T06:24:27.312365854Z","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","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":"in_progress","priority":0,"issue_type":"epic","assignee":"alpha","created_at":"2026-04-18T21:18:33.134146061Z","created_by":"coding","updated_at":"2026-04-19T06:40:54.458877301Z","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":""}]} @@ -137,11 +137,12 @@ {"id":"miroir-uyx.4","title":"P11.4 miroir-ctl subcommand docs + runbooks","description":"## What\n\nFor each `miroir-ctl` subcommand listed in plan §4 crate layout + §11 common operations:\n- `clap`-generated `--help` output covers flags + examples\n- A short runbook `docs/ctl/.md` with purpose, preconditions, examples, gotchas\n\nCommands covered:\n- `status`, `node add/drain`, `rebalance status --watch`, `verify`, `task status`\n- `reshard` (§13.1), `alias` (§13.7), `ttl` (§13.14), `cdc` (§13.13)\n- `shadow` (§13.16), `ui` (§13.19/§13.21 — scoped-key rotation, JWT rotation)\n- `tenant` (§13.15), `explain` (§13.20), `dump import` (§13.9), `canary` (§13.18)\n\n## Why\n\nPlan §12: \"`miroir-ctl --help` — all subcommands documented via clap.\" But `--help` alone isn't enough — operators need examples and gotchas. A good runbook is what prevents a 3-AM mis-run.\n\n## Details\n\n**Runbook template**:\n```markdown\n# `miroir-ctl `\n\n## Purpose\n\n\n## Preconditions\n- [ ] ...\n\n## Examples\n```\nmiroir-ctl ... --example\n```\n\n## Gotchas\n- ...\n\n## See also\n- Plan §X.X\n```\n\n**Integration with Admin UI (§13.19)**: many commands have a UI equivalent — runbook should cross-reference both (\"prefer UI for one-off; prefer CLI for scripts / CI\").\n\n## Acceptance\n\n- [ ] Every subcommand in the crate layout has a matching `docs/ctl/*.md` runbook\n- [ ] `miroir-ctl status --help` mentions where to find runbook for more\n- [ ] The runbooks are all under 100 lines each (easy to read before operating)","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:48:38.832471052Z","created_by":"coding","updated_at":"2026-04-18T21:48:38.832471052Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-11"],"dependencies":[{"issue_id":"miroir-uyx.4","depends_on_id":"miroir-uyx","type":"parent-child","created_at":"2026-04-18T21:48:38.832471052Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"miroir-uyx.5","title":"P11.5 Common issues + troubleshooting","description":"## What\n\nPlan §11 \"Common issues\" as structured troubleshooting docs at `docs/troubleshooting.md`:\n- \"primary key required\" — Miroir requires explicit primary key at index creation\n- \"Search returns fewer results than expected\" — degraded-node cross-reference + `GET /_miroir/topology`\n- \"Task polling stuck at processing\" — per-node task status via `miroir-ctl task status`\n\nPlus others discovered during Phase 9 testing and chaos scenarios.\n\n## Why\n\nEvery production system accumulates a list of \"the 10 things new users hit in their first week.\" Documenting them transparently shortens the mean-time-to-productive-user from hours to minutes.\n\n## Details\n\n**Per-issue structure**:\n```markdown\n## Error: \"primary key required\"\n\n### Symptom\nClient sees: `HTTP 400 { \"code\": \"miroir_primary_key_required\" }`\n\n### Cause\nThe index was created without a primary key. Miroir cannot route without one.\n\n### Fix\n```bash\ncurl -X POST https://miroir/indexes \\\n -H \"Authorization: Bearer $KEY\" \\\n -d '{\"uid\": \"myindex\", \"primaryKey\": \"id\"}'\n```\n\n### Why this differs from Meilisearch\nMeilisearch can infer the primary key from the first document batch. Miroir cannot — it needs to hash the PK *before* any node sees it. Explicit primary_key at index creation is required.\n```\n\n**Diagnostic playbook**: `docs/troubleshooting/diagnostics.md` — first thing to check for any symptom:\n1. `GET /_miroir/topology` — all nodes healthy?\n2. `GET /_miroir/metrics | grep degraded` — any degraded shards?\n3. `kubectl logs miroir-0 --tail=100 | jq 'select(.level==\"ERROR\")'` — recent errors?\n4. `kubectl get pods -n search` — all running?\n\n## Acceptance\n\n- [ ] 3 plan §11 issues documented with the template\n- [ ] At least 5 additional issues discovered in Phase 9 chaos added\n- [ ] Troubleshooting doc cross-linked from README, install guide, each migration guide","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:48:38.877214633Z","created_by":"coding","updated_at":"2026-04-18T21:48:38.877214633Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-11"],"dependencies":[{"issue_id":"miroir-uyx.5","depends_on_id":"miroir-uyx","type":"parent-child","created_at":"2026-04-18T21:48:38.877214633Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"miroir-uyx.6","title":"P11.6 Helm chart publication: GH Pages + OCI push","description":"## What\n\nPlan §12 delivered artifacts for the Helm chart:\n- **Primary**: `https://jedarden.github.io/miroir` (GitHub Pages, `gh-pages` branch)\n- **OCI**: `ghcr.io/jedarden/charts/miroir` (for air-gapped environments)\n\nExtend the Phase 8 Argo Workflow `miroir-ci` template with:\n- On tag: `helm package charts/miroir -d dist/`\n- Push to gh-pages: update `index.yaml` + copy `.tgz` into the branch, commit via `gh-pages` helper\n- OCI push: `helm push dist/miroir-.tgz oci://ghcr.io/jedarden/charts`\n\n## Why\n\nPlan §12: chart users expect `helm repo add` to work. Without publication, operators have to `helm install charts/miroir/` from a git clone — fine for dev, wrong for prod.\n\n## Details\n\n**gh-pages flow**:\n```bash\ngit worktree add gh-pages gh-pages\nhelm package charts/miroir -d gh-pages/\nhelm repo index gh-pages/ --url https://jedarden.github.io/miroir --merge gh-pages/index.yaml\ngit -C gh-pages add -A\ngit -C gh-pages commit -m \"Release chart v\"\ngit -C gh-pages push origin gh-pages\n```\n\n**OCI push** requires GHCR write token (already have in `ghcr-credentials`):\n```bash\necho $GHCR_TOKEN | helm registry login ghcr.io -u --password-stdin\nhelm push miroir-.tgz oci://ghcr.io/jedarden/charts\n```\n\n**Chart-only fixes**: when a chart change doesn't need an app rebuild, bump only chart version (not appVersion). CI must detect \"chart-only\" change (e.g., by diffing `charts/**` vs. `crates/**`) and skip the binary rebuild.\n\n## Acceptance\n\n- [ ] After `git tag v0.1.0 && git push`, `helm repo add miroir https://jedarden.github.io/miroir && helm repo update` discovers v0.1.0\n- [ ] `helm install ... oci://ghcr.io/jedarden/charts/miroir --version 0.1.0` works identically\n- [ ] Chart-only fix: tagging `v0.1.1` after editing only a template file bumps chart version without new app binary","status":"open","priority":2,"issue_type":"task","created_at":"2026-04-18T21:48:38.909893288Z","created_by":"coding","updated_at":"2026-04-18T21:48:38.909893288Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-11"],"dependencies":[{"issue_id":"miroir-uyx.6","depends_on_id":"miroir-uyx","type":"parent-child","created_at":"2026-04-18T21:48:38.909893288Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"miroir-yio","title":"Global-IDF preflight: implement dfs_query_then_fetch for cross-shard comparability","description":"## Context\n\nRRF validation (miroir-zfo) confirmed that RRF merge produces τ = 0.14 against ground truth — catastrophically worse than score-based merge (τ = 0.79). Neither strategy meets the 0.95 threshold.\n\nThe root cause is that shards with different document distributions compute different local IDF values, making scores and rankings incomparable across shards.\n\n## What\n\nImplement the Elasticsearch `dfs_query_then_fetch` pattern as a pre-query phase in Miroir:\n\n1. Coordinator sends a lightweight DFS (Distributed Frequency Search) request to all shards\n2. Each shard returns term-level document frequencies for the query terms\n3. Coordinator aggregates into global IDF values\n4. Coordinator sends the actual search query with global IDF attached\n5. Shards use global IDF for scoring instead of local IDF\n\n## Why\n\nThis is the proven solution. Both score-based merging (τ = 0.79) and RRF (τ = 0.14) fail the τ ≥ 0.95 quality threshold with skewed shards.\n\n## Scope\n\n- New `DfsPhase` in the scatter-gather pipeline\n- Coordinator-side IDF aggregation\n- Shard-side global-IDF scoring override\n- Integration test with skewed corpus\n- Benchmark to measure latency overhead of the preflight round\n\n## Depends on\n\n- miroir-zfo (RRF validation — complete)\n- miroir-zc2.4 (score normalization research — complete)","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-19T06:42:00.808359301Z","created_by":"coding","updated_at":"2026-04-19T06:42:00.808359301Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"miroir-zc2","title":"Phase 12 — Open Problems + Research (§15)","description":"## Phase 12 Epic — Open Problems Tracking\n\nStanding bucket for the plan §15 open problems that are **not** fully resolved by initial implementation. These are research/validation/future-enhancement beads, not blockers for v1.0. This phase does not block the genesis bead's shipping path — it's a parallel track that persists beyond v1.0.\n\n## Why An Epic At All\n\nPlan §15 flags these as \"documented constraints, not blockers. Initial release ships with known limitations.\" Tracking them as beads means they're not forgotten, they have a visible owner, and their resolution status can be surfaced alongside the rest of the work.\n\n## Scope — the 6 Open Problems (plan §15)\n\n1. **Shard migration write safety** — OP#1. **Status: partially addressed.** Dual-write cutover sequencing (Phase 4) + anti-entropy reconciler (§13.8 / Phase 5) catches slipped docs. Remaining work: chaos-test the cutover boundary, document any reproducible window where data could be lost if anti-entropy is disabled.\n\n2. **Task state HA (Raft vs. Redis)** — OP#2. **Status: deferred.** Current: Redis for multi-pod, SQLite for single-pod. Future: lightweight in-process Raft (or equivalent) so Redis is not required in HA. Not v1.x.\n\n3. **Resharding (S change) vs. node scaling (N change)** — OP#3. **Status: addressed by §13.1** (shadow-index dual-hash). Remaining work: empirical validation of the §13.1 \"2× transient storage and write load\" caveat under real corpora; schedule guidance in the CLI for off-peak reshard windows.\n\n4. **Score normalization at scale** — OP#4. **Status: settings-divergence addressed by §13.5 two-phase broadcast + drift reconciler.** Remaining work is purely statistical: validate that `_rankingScore` remains comparable across shards with very different document-count distributions. Requires corpus diversity tests.\n\n5. **Dump import distribution** — OP#5. **Status: addressed by §13.9 streaming routed dump import.** Broadcast mode retained as fallback. Remaining work: identify and enumerate every dump variant `mode: streaming` cannot fully reconstruct; either extend streaming or document the fallback trigger clearly.\n\n6. **arm64 support** — OP#6. **Status: not planned for v0.x.** Wire into CI when K8s ARM node support is actually needed (likely v1.x or later).\n\n## How To Use This Phase\n\n- Each OP becomes a child bead (bug/feature type) under this epic\n- Beads stay open until the status column above says \"fully addressed\"\n- v1.0 release notes should explicitly link to this epic so operators know what's still on the table\n- New open problems discovered during implementation get added here rather than silently accreted elsewhere\n\n## Not In Scope\n\n- Any concrete implementation work already covered by §13.1 / §13.5 / §13.8 / §13.9 — that belongs to Phase 5.","status":"open","priority":2,"issue_type":"epic","created_at":"2026-04-18T21:22:54.403910669Z","created_by":"coding","updated_at":"2026-04-18T21:22:54.403910669Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase","phase-12","research"]} {"id":"miroir-zc2.1","title":"P12.OP1 Shard migration write safety — cutover race window analysis","description":"## What\n\nPlan §15 Open Problem #1: \"Dual-write during migration must not lose documents that arrive exactly at the migration cutover boundary.\"\n\n**Status** per plan: partially addressed. Race window mitigated by §13.8 anti-entropy; any slipped doc caught on next reconciliation pass.\n\n**Remaining work**:\n- Chaos-test the cutover boundary — specifically: docs arriving at the instant of `active` transition (step 7 in plan §2 \"Adding a node\")\n- Document any reproducible window where data could be lost if anti-entropy is disabled\n- If found: extend Phase 4 dual-write to hold the window longer OR require anti-entropy to be on (hard-coded policy)\n\n## Why\n\n\"Plan §15 Open Problem 1 closure\" has been claimed in §13.8 — this bead verifies that claim empirically before we ship v1.0 committing to it.\n\n## Details\n\n**Chaos test design**:\n1. Start 3-node cluster, write 1000 docs\n2. Trigger node addition (`POST /_miroir/nodes`)\n3. During dual-write, rapid-fire new writes with tight (1ms) interval\n4. Tight-loop the transition from step 4 (migration complete) to step 7 (old replica deleted)\n5. Assert: every written doc retrievable AFTER step 7\n\n**Variants**:\n- With anti-entropy enabled (default) — expect 100% retrievable\n- With anti-entropy **disabled** — measure loss rate. If > 0, document + add a schema constraint refusing to enable migrations when anti-entropy is off\n\n## Acceptance\n\n- [ ] Chaos test published; runs on every v1.0-gating CI run\n- [ ] Loss rate measured at < 1 per 1M writes with AE on\n- [ ] Loss rate measured without AE; decision documented in `docs/trade-offs.md`\n- [ ] If `anti_entropy.enabled: false` + migration concurrent → loud warning log + (decided) refuse or warn","status":"closed","priority":2,"issue_type":"bug","assignee":"alpha","created_at":"2026-04-18T21:49:47.774525899Z","created_by":"coding","updated_at":"2026-04-19T02:01:02.057461283Z","closed_at":"2026-04-19T02:01:02.057395870Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","open-problem","phase-12","research"],"dependencies":[{"issue_id":"miroir-zc2.1","depends_on_id":"miroir-zc2","type":"parent-child","created_at":"2026-04-18T21:49:47.774525899Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"miroir-zc2.2","title":"P12.OP2 Task state HA — evaluate lightweight Raft vs. Redis requirement","description":"## What\n\nPlan §15 Open Problem #2: \"SQLite is single-writer. Running 2 Miroir replicas requires Redis. A future enhancement is a lightweight Raft-based in-process consensus so Redis is not required for HA mode.\"\n\n**Status** per plan: deferred. Current solution (Redis) works; Raft would remove an external dependency.\n\n**Research work**:\n- Survey embedded Raft crates: `openraft`, `raft-rs`, `async-raft`\n- Prototype: `TaskStore` trait impl backed by Raft state machine\n- Measure: latency + throughput vs. Redis; memory footprint per plan §14.2\n- Decide: ship in v1.x or never\n\n## Why\n\nRemoving Redis as a hard dependency shrinks the operational surface (one less thing to monitor, backup, rotate secrets for). But Raft adds complexity — a bad Raft impl can eat data in ways Redis doesn't.\n\nNot blocking v0.x or v1.0 — but worth prototyping before v2.0.\n\n## Details\n\n**Decision gate**: the Raft-backed path must be measurably better than Redis on at least one metric (ops simplicity, latency, or memory) without being worse on any of the others, before shipping.\n\n**Output**: `docs/research/raft-task-store.md` with the decision + benchmark data + reasoning. Keep or discard based on findings.\n\n## Acceptance\n\n- [ ] Research doc published with prototype branch linked\n- [ ] Decision recorded: ship / don't ship / revisit when","status":"closed","priority":3,"issue_type":"feature","assignee":"bravo","created_at":"2026-04-18T21:49:47.798646718Z","created_by":"coding","updated_at":"2026-04-19T02:57:16.452177084Z","closed_at":"2026-04-19T02:57:16.452114067Z","close_reason":"P12.OP2 complete. Surveyed openraft/raft-rs/async-raft (recommend openraft if revisited). Built feature-gated Raft state machine prototype at crates/miroir-core/src/raft_proto/ with benchmarks. Decision: do not ship Raft in v0.x/v1.0 -- Redis wins on write latency, throughput, correctness maturity, and operational tooling. Raft only wins on ops simplicity and read latency. Does not pass the decision gate. Revisit before v2.0 when Redis backend is production-stabilized and openraft reaches v1.0. Full analysis in docs/research/raft-task-store.md.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","open-problem","phase-12","research"],"dependencies":[{"issue_id":"miroir-zc2.2","depends_on_id":"miroir-zc2","type":"parent-child","created_at":"2026-04-18T21:49:47.798646718Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"miroir-zc2.3","title":"P12.OP3 Online resharding — validate 2× transient load caveat under real corpora","description":"## What\n\nPlan §15 Open Problem #3: §13.1 online resharding ships as a remediation, NOT a license to under-provision. Plan: \"doubles transient storage and write load; treat §13.1 as a remediation, not a license to under-provision.\"\n\n**Remaining work**:\n- Empirical validation of the 2× storage + write load estimate under real corpora (varied doc sizes, write rates, settings complexity)\n- CLI schedule guidance: `miroir-ctl reshard --schedule-window off-peak` — refuses to start outside a named window unless `--force`\n\n## Why\n\nOperators will over-commit to resharding if the \"2× transient\" caveat turns out to be 3× or worse in practice. Real numbers prevent that.\n\n## Details\n\n**Test matrix**:\n| Doc size | Corpus | Write rate | RG | RF | Measured peak storage |\n|----------|--------|------------|----|----|-----------------------|\n| 1 KB | 10 GB | 100 dps | 2 | 1 | ? |\n| 10 KB | 100 GB | 1000 dps | 2 | 2 | ? |\n| 1 MB (blobs) | 1 TB | 10 dps | 2 | 1 | ? |\n\nPublish results in `docs/benchmarks/resharding-load.md`.\n\n**CLI window guard**: config knob `resharding.allowed_windows: [\"02:00-06:00 UTC\"]`. CLI refuses outside windows without `--force`.\n\n## Acceptance\n\n- [ ] Benchmark doc published with real numbers\n- [ ] CLI window guard implemented; integration test confirms rejection outside window\n- [ ] Benchmark run in Phase 9 performance suite as part of v1.0 validation","status":"closed","priority":3,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:49:47.828099118Z","created_by":"coding","updated_at":"2026-04-19T02:09:48.450456008Z","closed_at":"2026-04-19T02:09:48.450390357Z","close_reason":"P12.OP3 complete. All acceptance criteria verified:\n\n1. Benchmark doc (docs/benchmarks/resharding-load.md): Published with results for all 3 scenarios (1KB/10GB, 10KB/100GB, 1MB/1TB). Storage amplification confirmed at exactly 2.00x and dual-write amplification at exactly 2.00x across all scenarios.\n\n2. CLI schedule window guard: Implemented in miroir-ctl reshard command. Config knob resharding.allowed_windows restricts resharding to named windows. CLI refuses outside windows unless --force given.\n\n3. Integration tests (window_guard.rs): 4 tests all passing. 24 total resharding tests pass.\n\n4. Benchmark binary (reshard_load.rs): Full simulation using actual routing code, validates invariants.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","open-problem","phase-12","research"],"dependencies":[{"issue_id":"miroir-zc2.3","depends_on_id":"miroir-zc2","type":"parent-child","created_at":"2026-04-18T21:49:47.828099118Z","created_by":"coding","metadata":"{}","thread_id":""}]} -{"id":"miroir-zc2.4","title":"P12.OP4 Score normalization at scale — statistical validation of cross-shard comparability","description":"## What\n\nPlan §15 Open Problem #4: \"`_rankingScore` is comparable across shards only when index settings are identical.\" Settings divergence addressed by §13.5; remaining concern is statistical — do scores stay comparable when shards have very different document-count distributions?\n\n**Research work**:\n- Build a test corpus with intentionally skewed shard populations (one shard 100×, another shard 0.01× the median)\n- Submit identical queries; measure score distribution per shard\n- Assert: top-K merged ordering matches a ground-truth single-index version within some ε\n- If large ε, document + possibly introduce a score normalization pass\n\n## Why\n\nElasticsearch (plan research doc §1) hits this exactly: \"BM25 scoring depends on IDF, computed per shard by default using only that shard's local term statistics.\" Meilisearch uses its own ranking pipeline, but the same issue applies — local rank stats can drift from global on skewed shards.\n\n## Details\n\n**Ground truth**: single-index Meilisearch running the same queries against the same corpus.\n\n**Divergence metric**: Kendall τ between Miroir result ordering and single-index result ordering across 10k random queries.\n\n**If τ < 0.95 on average**: investigate whether a global IDF-style preflight is worth adding (plan research §1 \"`dfs_query_then_fetch`\" pattern).\n\n**Output**: `docs/research/score-normalization-at-scale.md`.\n\n## Acceptance\n\n- [ ] Benchmark corpus + query set published in `tests/benches/score-comparability/`\n- [ ] Results reported with confidence intervals\n- [ ] If τ < 0.95: follow-up bead created for a normalization pass\n- [ ] If τ ≥ 0.95: note-of-no-action in the bead's close comment","status":"in_progress","priority":3,"issue_type":"task","assignee":"charlie","created_at":"2026-04-18T21:49:47.849019120Z","created_by":"coding","updated_at":"2026-04-19T06:37:20.279671230Z","close_reason":"P12.OP4 complete. Score-based merge τ=0.79, RRF τ=0.14 — both fail 0.95 threshold.\nFollow-up bead miroir-n6v created for global-IDF preflight (dfs_query_then_fetch pattern).\nBenchmark corpus + query set in tests/benches/score-comparability/. Research doc at docs/research/score-normalization-at-scale.md.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","open-problem","phase-12","research"],"dependencies":[{"issue_id":"miroir-zc2.4","depends_on_id":"miroir-nsu","type":"blocks","created_at":"2026-04-19T03:56:41.560992652Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-zc2.4","depends_on_id":"miroir-zc2","type":"parent-child","created_at":"2026-04-18T21:49:47.849019120Z","created_by":"coding","metadata":"{}","thread_id":""}]} +{"id":"miroir-zc2.4","title":"P12.OP4 Score normalization at scale — statistical validation of cross-shard comparability","description":"## What\n\nPlan §15 Open Problem #4: \"`_rankingScore` is comparable across shards only when index settings are identical.\" Settings divergence addressed by §13.5; remaining concern is statistical — do scores stay comparable when shards have very different document-count distributions?\n\n**Research work**:\n- Build a test corpus with intentionally skewed shard populations (one shard 100×, another shard 0.01× the median)\n- Submit identical queries; measure score distribution per shard\n- Assert: top-K merged ordering matches a ground-truth single-index version within some ε\n- If large ε, document + possibly introduce a score normalization pass\n\n## Why\n\nElasticsearch (plan research doc §1) hits this exactly: \"BM25 scoring depends on IDF, computed per shard by default using only that shard's local term statistics.\" Meilisearch uses its own ranking pipeline, but the same issue applies — local rank stats can drift from global on skewed shards.\n\n## Details\n\n**Ground truth**: single-index Meilisearch running the same queries against the same corpus.\n\n**Divergence metric**: Kendall τ between Miroir result ordering and single-index result ordering across 10k random queries.\n\n**If τ < 0.95 on average**: investigate whether a global IDF-style preflight is worth adding (plan research §1 \"`dfs_query_then_fetch`\" pattern).\n\n**Output**: `docs/research/score-normalization-at-scale.md`.\n\n## Acceptance\n\n- [ ] Benchmark corpus + query set published in `tests/benches/score-comparability/`\n- [ ] Results reported with confidence intervals\n- [ ] If τ < 0.95: follow-up bead created for a normalization pass\n- [ ] If τ ≥ 0.95: note-of-no-action in the bead's close comment","status":"closed","priority":3,"issue_type":"task","assignee":"charlie","created_at":"2026-04-18T21:49:47.849019120Z","created_by":"coding","updated_at":"2026-04-19T06:41:46.830230930Z","closed_at":"2026-04-19T06:41:46.829923593Z","close_reason":"P12.OP4 score normalization validation complete. Score-based merge τ=0.79, RRF τ=0.14 — both fail 0.95 threshold. Follow-up bead miroir-n6v created for global-IDF preflight (dfs_query_then_fetch pattern). Benchmark corpus in tests/benches/score-comparability/. Research doc at docs/research/score-normalization-at-scale.md.","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred","failure-count:1","open-problem","phase-12","research"],"dependencies":[{"issue_id":"miroir-zc2.4","depends_on_id":"miroir-nsu","type":"blocks","created_at":"2026-04-19T03:56:41.560992652Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-zc2.4","depends_on_id":"miroir-zc2","type":"parent-child","created_at":"2026-04-18T21:49:47.849019120Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"miroir-zc2.5","title":"P12.OP5 Dump import variants — enumerate what streaming mode can't handle","description":"## What\n\nPlan §15 Open Problem #5: §13.9 streaming routed dump import addresses the main case; broadcast mode retained as a fallback for dump variants Miroir cannot fully reconstruct via public API.\n\n**Remaining work**:\n- Identify and enumerate every dump variant streaming can't reconstruct\n- Either extend streaming to handle them OR document the fallback trigger clearly in `miroir-ctl dump import --help`\n\n## Why\n\n\"Can't reconstruct\" is vague — operators deserve concrete lists of what works and what doesn't. Without this, the `broadcast` fallback path is a bug waiting to happen.\n\n## Details\n\n**Potential failure modes to investigate**:\n- Dumps from older Meilisearch versions with pre-v1.37 schema\n- Dumps with custom keys (POST /keys) that have indexes list or actions not representable via public API\n- Dumps with snapshot-taken-mid-write where Miroir-injected `_miroir_shard` would conflict with an existing client field\n\n**Deliverable**: `docs/dump-import/compatibility-matrix.md` with columns:\n| Meilisearch version | Dump variant | Streaming works? | Broadcast needed? | Workaround |\n\n## Acceptance\n\n- [ ] Matrix published\n- [ ] Each \"broadcast needed\" row has a workaround or a link to an open enhancement bead\n- [ ] `miroir-ctl dump import` output references the matrix when falling back to broadcast","status":"closed","priority":3,"issue_type":"task","assignee":"bravo","created_at":"2026-04-18T21:49:47.884303207Z","created_by":"coding","updated_at":"2026-04-19T01:09:27.327131515Z","closed_at":"2026-04-19T01:09:27.327067549Z","close_reason":"Compatibility matrix published at docs/dump-import/compatibility-matrix.md\n\n- Matrix enumerates all dump variants that streaming mode can/cannot reconstruct\n- Each broadcast fallback row has workaround or enhancement bead link\n- CLI output reference section documents fallback message\n- Covers: version compatibility, field conflicts, EE features, snapshots, corrupted dumps","source_repo":".","compaction_level":0,"original_size":0,"labels":["failure-count:1","open-problem","phase-12","research"],"dependencies":[{"issue_id":"miroir-zc2.5","depends_on_id":"miroir-zc2","type":"parent-child","created_at":"2026-04-18T21:49:47.884303207Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"miroir-zc2.6","title":"P12.OP6 arm64 support (deferred to v1.x+)","description":"## What\n\nPlan §15 Open Problem #6: \"Not planned for v0.x. Added when K8s ARM node support is required.\"\n\n**Future work when prioritized**:\n- Cross-compile `miroir-proxy` and `miroir-ctl` for `aarch64-unknown-linux-musl` in the CI pipeline\n- Docker image manifest list: `ghcr.io/jedarden/miroir:` spans `linux/amd64` + `linux/arm64`\n- Helm chart: no changes (binary is arch-agnostic at the k8s layer)\n- Phase 9 CI: add arm64 test runs\n\n## Why\n\nARM node support is increasingly common (Hetzner Ampere, AWS Graviton, GCP Tau T2A, Rackspace Spot). But Miroir's fleet is currently all amd64 (iad-ci is amd64; ardenone cluster nodes are amd64). No current demand to justify the CI complexity.\n\nKeep this bead open as a placeholder; promote to in-progress when a concrete use case emerges.\n\n## Details\n\n**When ready**: the Argo Workflow `cargo-build` step needs a matrix over targets:\n```yaml\n- name: cargo-build\n container:\n args:\n - |\n rustup target add x86_64-unknown-linux-musl\n rustup target add aarch64-unknown-linux-musl\n apt-get install -qy musl-tools gcc-aarch64-linux-gnu\n cargo build --release --target x86_64-unknown-linux-musl -p miroir-proxy\n cargo build --release --target aarch64-unknown-linux-musl -p miroir-proxy\n ...\n```\n\nKaniko build needs `--customPlatform=linux/amd64,linux/arm64` or equivalent for multi-arch manifests.\n\n## Acceptance\n\n- [ ] Not to be closed until arm64 is a live deliverable\n- [ ] Cross-reference here when the priority flips","status":"in_progress","priority":4,"issue_type":"feature","assignee":"charlie","created_at":"2026-04-18T21:49:47.917666333Z","created_by":"coding","updated_at":"2026-04-19T00:58:19.767272778Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["open-problem","phase-12","roadmap"],"dependencies":[{"issue_id":"miroir-zc2.6","depends_on_id":"miroir-zc2","type":"parent-child","created_at":"2026-04-18T21:49:47.917666333Z","created_by":"coding","metadata":"{}","thread_id":""}]} {"id":"miroir-zfo","title":"P12.OP4 follow-up: Validate RRF merging quality with score-comparability benchmark","description":"## Context\n\nScore normalization research (miroir-zc2.4) found that raw _rankingScore merging gives Kendall τ = 0.79 vs ground truth — well below the 0.95 threshold. RRF merging is already implemented in merger.rs as the mitigation.\n\n## What\n\nRe-run the score-comparability benchmark using Miroir's actual RRF merger (instead of the score-based merge in simulate.py) and measure τ against ground truth. This validates that RRF solves the cross-shard comparability problem.\n\n## Steps\n1. Add an RRF merge mode to simulate.py (or write a Rust test that uses the actual merger)\n2. Re-run with the same 10K query set against the skewed corpus\n3. Measure Kendall τ between RRF-merged results and single-index ground truth\n4. If τ ≥ 0.95: close with note-of-no-action\n5. If τ < 0.95: investigate global-IDF preflight (plan §1 dfs_query_then_fetch pattern)\n\n## Acceptance\n- [ ] RRF merge benchmarked against ground truth\n- [ ] τ reported with 95% CI\n- [ ] If τ < 0.95: create bead for global-IDF preflight implementation","status":"in_progress","priority":2,"issue_type":"issue","assignee":"alpha","created_at":"2026-04-19T04:06:52.077073258Z","created_by":"coding","updated_at":"2026-04-19T06:33:48.936667617Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["deferred"]}