# Control-plane DB substrate (#613) **Status:** Implemented (SQLite single-writer MVP) **ADR:** [`mcp-allocator-control-plane-observability-adr.md`](mcp-allocator-control-plane-observability-adr.md) **Module:** `control_plane_db.py` ## Architecture statement > **DB coordinates, Gitea records, Sentry/GlitchTip observe; the bridge is the only path that turns observations into Gitea work.** ## What this ships | Capability | Notes | |------------|--------| | Schema | `sessions`, `work_items`, `leases`, `assignments`, `terminal_locks`, `events`, `incident_links` | | Atomic assign+lease | `ControlPlaneDB.assign_and_lease` — one `BEGIN IMMEDIATE` transaction | | Mutation gate | `require_valid_assignment` — live lease + allowed action + non-terminal work + non-stale head | | Heartbeat / release / expire | Lease lifecycle helpers | | Terminal-lock index | Routing signal for #600 (terminal path first) | | `incident_links` | Provider-neutral link model for #612 — **not** assignable work; scope keys NULL-safe | ## Hard rules (enforced in code) 1. Assignable `work_items.kind` ∈ {`issue`, `pr`} only — **never** raw Sentry/GlitchTip incidents. 2. Two concurrent sessions cannot both receive an active assignment on the same open work item (second gets `wait`). 3. Merged/closed work items return `no_safe_work` at assign time, and `require_valid_assignment` fails closed if the work item becomes terminal later. 4. Assignments pin `expected_head_sha`; mutations fail closed if the work item head drifts. 5. SQLite path is the **single-writer MVP** (`GITEA_CONTROL_PLANE_DB`, default under `~/.cache/gitea-tools/control-plane/`). Multi-session multi-host production requires **Postgres** or a **single allocator daemon** (ADR §6). 6. `incident_links` optional scope fields are stored as empty strings (never NULL) so UNIQUE is canonical across minimal upserts. 7. Legacy `incident_links` migration collapses NULL-scope duplicates **only** when Gitea targets **and** all meaningful observation metadata agree (fingerprint, status, event_count, permalink, timestamps, linked PRs, etc.). Conflicting metadata fails closed — no silent discard. ## Dependency chain ```text #613 control-plane DB (this) → #600 allocator API → #612 incident bridge ``` - **#600** must call this substrate (not file locks / comment-only leases alone) for completion. - **#612** must write `incident_links` here and create **Gitea issues**; the allocator assigns those issues, not raw incidents. ## Allocator API (#600) Module: `allocator_service.py` · MCP tool: `gitea_allocate_next_work` - Workers call `gitea_allocate_next_work(apply=false|true)` instead of self-selecting work. - `apply=false` returns a dry-run selection (`outcome=preview`) with skip reasons. - `apply=true` reserves via `ControlPlaneDB.assign_and_lease` (atomic assignment+lease). - Coordination source is **always** the control-plane DB — not file locks or comment-only leases. - Routing follows ADR §5.3 (REQUEST_CHANGES → author, terminal path first, foreign lease → wait). - Raw monitoring incidents are never candidates (#612 remains downstream). ## Non-goals (intentionally deferred) - Sentry/GlitchTip provider adapters and auto-watchdog — **#612** - Gitea comment/label mirror writers (may be added by allocator tooling later) ## Tests ```bash python3 -m pytest tests/test_control_plane_db.py -q ```