fix: fail closed on stale/terminal assignments; canonicalize incident_links

Address REQUEST_CHANGES on PR #619:

- require_valid_assignment rejects terminal work states and head drift
- incident_links scope keys normalize NULL/blank to '' for UNIQUE
- remove trailing whitespace in control-plane-db-substrate.md
This commit is contained in:
2026-07-09 22:41:25 -04:00
parent 036b78e31e
commit 783ea88a01
3 changed files with 244 additions and 25 deletions
+80 -18
View File
@@ -29,11 +29,14 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Iterator, Sequence from typing import Any, Iterator, Sequence
SCHEMA_VERSION = 1 SCHEMA_VERSION = 2
# Assignable work kinds only — raw monitoring incidents are never work items. # Assignable work kinds only — raw monitoring incidents are never work items.
WORK_KINDS = frozenset({"issue", "pr"}) WORK_KINDS = frozenset({"issue", "pr"})
# Work item states that permanently revoke assignment/lease authority.
TERMINAL_WORK_STATES = frozenset({"merged", "closed"})
DEFAULT_LEASE_TTL_SECONDS = 4 * 3600 DEFAULT_LEASE_TTL_SECONDS = 4 * 3600
# Environment: path for SQLite MVP (single-writer). # Environment: path for SQLite MVP (single-writer).
@@ -120,12 +123,13 @@ CREATE TABLE IF NOT EXISTS events (
); );
-- Provider-neutral incident ↔ Gitea link model (ADR §89). Not assignable work. -- Provider-neutral incident ↔ Gitea link model (ADR §89). Not assignable work.
-- Optional scope fields are stored as '' (never NULL) so UNIQUE is NULL-safe.
CREATE TABLE IF NOT EXISTS incident_links ( CREATE TABLE IF NOT EXISTS incident_links (
link_id INTEGER PRIMARY KEY AUTOINCREMENT, link_id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL, provider TEXT NOT NULL,
provider_base_url TEXT, provider_base_url TEXT NOT NULL DEFAULT '',
provider_org TEXT, provider_org TEXT NOT NULL DEFAULT '',
provider_project TEXT, provider_project TEXT NOT NULL DEFAULT '',
provider_issue_id TEXT NOT NULL, provider_issue_id TEXT NOT NULL,
provider_short_id TEXT, provider_short_id TEXT,
provider_permalink TEXT, provider_permalink TEXT,
@@ -172,6 +176,17 @@ def _parse_ts(value: str | None) -> datetime | None:
return None return None
def _norm_scope(value: str | None) -> str:
"""Normalize optional incident-link scope keys for NULL-safe uniqueness.
Empty / whitespace / None all become '' so SQLite UNIQUE treats them as one
canonical key component (SQLite treats multiple NULLs as distinct).
"""
if value is None:
return ""
return str(value).strip()
def default_db_path() -> str: def default_db_path() -> str:
raw = (os.environ.get(DB_PATH_ENV) or DEFAULT_DB_PATH).strip() raw = (os.environ.get(DB_PATH_ENV) or DEFAULT_DB_PATH).strip()
return raw or DEFAULT_DB_PATH return raw or DEFAULT_DB_PATH
@@ -285,6 +300,7 @@ class ControlPlaneDB:
conn = self._connect() conn = self._connect()
try: try:
conn.executescript(_SCHEMA_SQL) conn.executescript(_SCHEMA_SQL)
self._migrate_incident_links_null_scope(conn)
conn.execute( conn.execute(
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)", "INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)",
("schema_version", str(SCHEMA_VERSION)), ("schema_version", str(SCHEMA_VERSION)),
@@ -301,6 +317,35 @@ class ControlPlaneDB:
finally: finally:
conn.close() conn.close()
def _migrate_incident_links_null_scope(self, conn: sqlite3.Connection) -> None:
"""Coerce NULL scope columns to '' and collapse any duplicate keys (#619 RC)."""
cols = {
row[1]
for row in conn.execute("PRAGMA table_info(incident_links)").fetchall()
}
if not cols:
return
for col in ("provider_base_url", "provider_org", "provider_project"):
if col in cols:
conn.execute(
f"UPDATE incident_links SET {col} = '' WHERE {col} IS NULL"
)
# Drop duplicates keeping lowest link_id (canonical row).
conn.execute(
"""
DELETE FROM incident_links
WHERE link_id NOT IN (
SELECT MIN(link_id)
FROM incident_links
GROUP BY provider,
IFNULL(provider_base_url, ''),
IFNULL(provider_org, ''),
IFNULL(provider_project, ''),
provider_issue_id
)
"""
)
# ── sessions ────────────────────────────────────────────────────────── # ── sessions ──────────────────────────────────────────────────────────
def upsert_session( def upsert_session(
@@ -730,7 +775,12 @@ class ControlPlaneDB:
number: int, number: int,
action: str, action: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Gate a mutation: require active assignment+lease for this session/work.""" """Gate a mutation: require active assignment+lease for this session/work.
Fail-closed when the work item is terminal (merged/closed) or when the
assignment's expected_head_sha no longer matches the work item head
(stale-head after assignment time).
"""
kind_norm = (kind or "").strip().lower() kind_norm = (kind or "").strip().lower()
if kind_norm not in WORK_KINDS: if kind_norm not in WORK_KINDS:
raise InvalidWorkKindError(f"invalid kind '{kind}'") raise InvalidWorkKindError(f"invalid kind '{kind}'")
@@ -746,6 +796,12 @@ class ControlPlaneDB:
raise LeaseRequiredError( raise LeaseRequiredError(
f"no work_item for {kind_norm}#{number}; assign first" f"no work_item for {kind_norm}#{number}; assign first"
) )
work_state = (work["state"] or "").strip().lower()
if work_state in TERMINAL_WORK_STATES:
raise LeaseRequiredError(
f"work item {kind_norm}#{number} is terminal state "
f"'{work_state}'; assignment no longer authorizes mutations"
)
self._expire_stale_leases_conn(conn, work_item_id=int(work["work_item_id"])) self._expire_stale_leases_conn(conn, work_item_id=int(work["work_item_id"]))
asn = conn.execute( asn = conn.execute(
""" """
@@ -769,6 +825,14 @@ class ControlPlaneDB:
exp = _parse_ts(asn["expires_at"]) exp = _parse_ts(asn["expires_at"])
if exp and exp <= _utc_now(): if exp and exp <= _utc_now():
raise LeaseRequiredError(f"lease {asn['lease_id']} expired") raise LeaseRequiredError(f"lease {asn['lease_id']} expired")
# Stale-head: pinned expected_head_sha must still match live work item.
assigned_head = (asn["expected_head_sha"] or "").strip()
current_head = (work["current_head_sha"] or "").strip()
if assigned_head and assigned_head != current_head:
raise LeaseRequiredError(
f"assignment {asn['assignment_id']} stale head: expected "
f"{assigned_head!r} but work item head is {current_head!r}"
)
allowed = json.loads(asn["allowed_actions"]) allowed = json.loads(asn["allowed_actions"])
forbidden = json.loads(asn["forbidden_actions"]) forbidden = json.loads(asn["forbidden_actions"])
if action in forbidden: if action in forbidden:
@@ -867,6 +931,10 @@ class ControlPlaneDB:
status: str = "open", status: str = "open",
) -> dict[str, Any]: ) -> dict[str, Any]:
now_s = _ts() now_s = _ts()
# Canonical key: never store NULL in UNIQUE scope columns.
base_url = _norm_scope(provider_base_url)
p_org = _norm_scope(provider_org)
p_project = _norm_scope(provider_project)
with self._tx() as conn: with self._tx() as conn:
conn.execute( conn.execute(
""" """
@@ -892,9 +960,9 @@ class ControlPlaneDB:
""", """,
( (
provider, provider,
provider_base_url, base_url,
provider_org, p_org,
provider_project, p_project,
provider_issue_id, provider_issue_id,
provider_short_id, provider_short_id,
provider_permalink, provider_permalink,
@@ -914,18 +982,12 @@ class ControlPlaneDB:
""" """
SELECT * FROM incident_links SELECT * FROM incident_links
WHERE provider = ? WHERE provider = ?
AND IFNULL(provider_base_url, '') = IFNULL(?, '') AND provider_base_url = ?
AND IFNULL(provider_org, '') = IFNULL(?, '') AND provider_org = ?
AND IFNULL(provider_project, '') = IFNULL(?, '') AND provider_project = ?
AND provider_issue_id = ? AND provider_issue_id = ?
""", """,
( (provider, base_url, p_org, p_project, provider_issue_id),
provider,
provider_base_url,
provider_org,
provider_project,
provider_issue_id,
),
).fetchone() ).fetchone()
return dict(row) return dict(row)
@@ -1,7 +1,9 @@
# Control-plane DB substrate (#613) # Control-plane DB substrate (#613)
**Status:** Implemented (SQLite single-writer MVP) **Status:** Implemented (SQLite single-writer MVP)
**ADR:** [`mcp-allocator-control-plane-observability-adr.md`](mcp-allocator-control-plane-observability-adr.md)
**ADR:** [`mcp-allocator-control-plane-observability-adr.md`](mcp-allocator-control-plane-observability-adr.md)
**Module:** `control_plane_db.py` **Module:** `control_plane_db.py`
## Architecture statement ## Architecture statement
@@ -14,17 +16,19 @@
|------------|--------| |------------|--------|
| Schema | `sessions`, `work_items`, `leases`, `assignments`, `terminal_locks`, `events`, `incident_links` | | Schema | `sessions`, `work_items`, `leases`, `assignments`, `terminal_locks`, `events`, `incident_links` |
| Atomic assign+lease | `ControlPlaneDB.assign_and_lease` — one `BEGIN IMMEDIATE` transaction | | Atomic assign+lease | `ControlPlaneDB.assign_and_lease` — one `BEGIN IMMEDIATE` transaction |
| Mutation gate | `require_valid_assignment`workers need a live assignment/lease | | Mutation gate | `require_valid_assignment`live lease + allowed action + non-terminal work + non-stale head |
| Heartbeat / release / expire | Lease lifecycle helpers | | Heartbeat / release / expire | Lease lifecycle helpers |
| Terminal-lock index | Routing signal for #600 (terminal path first) | | Terminal-lock index | Routing signal for #600 (terminal path first) |
| `incident_links` | Provider-neutral link model for #612**not** assignable work | | `incident_links` | Provider-neutral link model for #612**not** assignable work; scope keys NULL-safe |
## Hard rules (enforced in code) ## Hard rules (enforced in code)
1. Assignable `work_items.kind` ∈ {`issue`, `pr`} only — **never** raw Sentry/GlitchTip incidents. 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`). 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`. 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. 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). 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.
## Dependency chain ## Dependency chain
+154 -1
View File
@@ -36,7 +36,7 @@ class ControlPlaneDBTest(unittest.TestCase):
rows = dict(conn.execute("SELECT key, value FROM schema_meta").fetchall()) rows = dict(conn.execute("SELECT key, value FROM schema_meta").fetchall())
finally: finally:
conn.close() conn.close()
self.assertEqual(rows["schema_version"], "1") self.assertEqual(rows["schema_version"], "2")
self.assertIn("DB coordinates", rows["architecture"]) self.assertIn("DB coordinates", rows["architecture"])
self.assertIn("bridge", rows["architecture"].lower()) self.assertIn("bridge", rows["architecture"].lower())
@@ -372,6 +372,159 @@ class ControlPlaneDBTest(unittest.TestCase):
self.assertEqual(b.outcome, "assigned") self.assertEqual(b.outcome, "assigned")
self.assertEqual(b.session_id, "s2") self.assertEqual(b.session_id, "s2")
def test_require_valid_assignment_rejects_stale_head(self) -> None:
"""Assignment must not authorize mutations after work-item head drifts."""
self.db.upsert_session(session_id="s1", role="author")
self.db.assign_and_lease(
session_id="s1",
role="author",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=42,
expected_head_sha="head-v1",
allowed_actions=("implement",),
)
# Head drifts after assignment
self.db.upsert_work_item(
remote="prgs",
org="o",
repo="r",
kind="pr",
number=42,
current_head_sha="head-v2",
)
with self.assertRaises(LeaseRequiredError) as ctx:
self.db.require_valid_assignment(
session_id="s1",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=42,
action="implement",
)
self.assertIn("stale head", str(ctx.exception).lower())
def test_require_valid_assignment_rejects_terminal_state(self) -> None:
"""Assignment must not authorize mutations after work item is merged/closed."""
self.db.upsert_session(session_id="s1", role="author")
self.db.assign_and_lease(
session_id="s1",
role="author",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=55,
expected_head_sha="abc",
allowed_actions=("implement",),
)
self.db.upsert_work_item(
remote="prgs",
org="o",
repo="r",
kind="pr",
number=55,
state="merged",
current_head_sha="abc",
)
with self.assertRaises(LeaseRequiredError) as ctx:
self.db.require_valid_assignment(
session_id="s1",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=55,
action="implement",
)
self.assertIn("terminal", str(ctx.exception).lower())
self.db.upsert_work_item(
remote="prgs",
org="o",
repo="r",
kind="issue",
number=56,
state="open",
)
self.db.assign_and_lease(
session_id="s1",
role="author",
remote="prgs",
org="o",
repo="r",
kind="issue",
number=56,
allowed_actions=("implement",),
)
self.db.upsert_work_item(
remote="prgs",
org="o",
repo="r",
kind="issue",
number=56,
state="closed",
)
with self.assertRaises(LeaseRequiredError):
self.db.require_valid_assignment(
session_id="s1",
remote="prgs",
org="o",
repo="r",
kind="issue",
number=56,
action="implement",
)
def test_incident_links_minimal_upsert_is_canonical(self) -> None:
"""Repeated minimal upserts must update one row (NULL-safe uniqueness)."""
a = self.db.upsert_incident_link(
provider="sentry",
provider_issue_id="inc-1",
gitea_org="org",
gitea_repo="repo",
gitea_issue_number=1,
)
b = self.db.upsert_incident_link(
provider="sentry",
provider_issue_id="inc-1",
gitea_org="org",
gitea_repo="repo",
gitea_issue_number=2,
# omit optional scope fields again
)
c = self.db.upsert_incident_link(
provider="sentry",
provider_issue_id="inc-1",
gitea_org="org",
gitea_repo="repo",
gitea_issue_number=3,
provider_base_url=None,
provider_org="",
provider_project=" ",
)
self.assertEqual(a["link_id"], b["link_id"])
self.assertEqual(b["link_id"], c["link_id"])
self.assertEqual(c["gitea_issue_number"], 3)
self.assertEqual(c["provider_base_url"], "")
self.assertEqual(c["provider_org"], "")
self.assertEqual(c["provider_project"], "")
import sqlite3
conn = sqlite3.connect(self.db_path)
try:
n = conn.execute(
"SELECT COUNT(*) FROM incident_links WHERE provider_issue_id = ?",
("inc-1",),
).fetchone()[0]
finally:
conn.close()
self.assertEqual(n, 1)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()