Commit b6b495c8e51712ad0381408fd722aadb064ff7ef

Authored by bboonnee
1 parent 3dfcdd6a

U-034: deployment/deploy_health_signal tables + core_engine enum (PH5 start)

Adds the two Deploy Supervisor entities from 01-M §1.15/§1.16 (17 tables
total): deployment (full 15-value blue-green state machine, per 01-M's own
text rather than supervisor-auto-rollback-design.md §3's abbreviated
summary table) and deploy_health_signal (append-only health/monitoring
samples). ux_deployment_active enforces 01-M's "only one non-terminal
deployment at a time" constraint via an expression-based unique index
(CREATE UNIQUE INDEX ... ON deployment((1)) WHERE status NOT IN (...)) --
the same partial-unique-index idiom work_graph_run already uses, just with
a constant instead of a real grouping column since there's exactly one
graphlemon self-target.

bug_record.root_cause_category's schema-level CHECK now accepts
core_engine, but bug_record.py's own application-layer whitelist
deliberately does not follow yet -- classifying a bug as core_engine is a
file-path-based rule (05 §5.8), not something the Validator's existing
3-perspective judge_root_cause can produce, and wiring that classification
+ c_org_loop_route's routing is U-035's job.

392 tests green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
... ... @@ -336,6 +336,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
336 336 - **U-034 [Enabler]** 01-M 扩展:新增 `deployment`、`deploy_health_signal` 两个实体建表;`bug_record.root_cause_category` 新增 `core_engine` 枚举值
337 337 - 依赖:U-001
338 338 - Done:两张新表建表成功并通过约束校验;`bug_record` 现有记录/查询逻辑对新枚举值向后兼容(不破坏 U-015/U-018 已有测试)
  339 + - **实现补充(2026-08-02)**:按 01-M §1.15/1.16(比 `supervisor-auto-rollback-design.md` §3 的概要表更完整,以 01-M 为准)在 `src/schema.py` 追加两张表;`deployment.status` 用 01-M 原文的完整 15 值状态机枚举(不是 §3 表格里省略过的简化版)。`ux_deployment_active` 用表达式索引 `CREATE UNIQUE INDEX ... ON deployment((1)) WHERE status NOT IN (...)` 实现"同一时刻只允许一条非终态 deployment"的全局单例约束——和 `work_graph_run` 现有的按 `(task_id, org_node)` 分组的 partial unique index 是同一个思路,只是这里没有天然的分组键(graphlemon 自己就一份,不是"每个 X 一条"),用常量表达式当分组键达到"全局只有一条"的效果,本地 sqlite3 验证过真实生效。`bug_record.root_cause_category` CHECK 约束加了 `core_engine`,但 `bug_record.py` 自己的应用层 `_VALID_ROOT_CAUSE_CATEGORIES` 白名单**刻意没有跟着放开**——"什么时候一个 bug 该归为 core_engine"是 05 §5.8 定义的按文件路径判断的规则(`.claude/agents/*.md`/Graph Engine 核心逻辑/01-M 自身 schema 变更),不是 Validator 现有三视角 `judge_root_cause` 能判断的东西(那三个视角硬编码只产出 planning/coder/skill_defect),这条分类逻辑连同 `c_org_loop_route` 的路由扩展一起留给 U-035,不是这个 Unit 该做的。`previous_commit_hash`/`previous_instance_dir` 在 01-M 里标了 not null,但第一次真正跑 Deploy Supervisor 时没有"上一条 finalized deployment"可取——这个引导期边界情况留给 U-037 的 `c_deploy_supervisor_stage` 处理(第一次调用时把当前正在跑的 commit/checkout 目录当成"previous"传进去),不是 schema 层该解决的,已在 `schema.py` docstring 里记录。
339 340 - 覆盖:`supervisor-auto-rollback-design.md` §3;`self-development-feasibility.md` 缺口 3
340 341  
341 342 - **U-035 [Risk Gate]** self-target 场景 worktree 无条件强制规则(缺口 1)+ `root_cause_category=core_engine` 命中时路由到蓝绿部署流程而非 `fast_lane`(`c_org_loop_route` 扩展)
... ...
docs/sdd/unit_matrix.json
... ... @@ -51,7 +51,7 @@
51 51 {"id": "U-032", "phase": "PH4", "sub_phase": "PH4-S2", "name": "多 Goal 并发 + 长时间运行稳定性压测(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-031"], "coverage": {"model": ["org_concurrency_config"], "view": [], "controller": [], "process": [], "requirement": ["REQ-09"]}, "done": "并发数调高后多个独立 Goal 可同时稳定运行;发现并补上一个此前从未存在的结构性缺口——04-P/01-M/REQ-09 boundary 反复假设存在的自动调度器实际从未被建过,新增 src/scheduler.py(ThreadPoolExecutor + 每线程独立 sqlite 连接,避免复刻 U-027 的共享连接锁陷阱)真正实现,并顺带补上 e2e_pipeline.run_end_to_end() 自己文档过的缺口(PASS/FAIL 后从不调用 close_task_done/run_fix_loop,卡在 validating 不动)"},
52 52 {"id": "U-033", "phase": "PH4", "sub_phase": "PH4-S3", "name": "A-2 假设收口(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-004"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": [], "assumption": ["A-2"]}, "done": "A-2 状态确认为 confirmed 或记录实际偏差;最终结论:维持 U-004 记录的\"部分成立\",PH1~PH4 积累的真实运行数据未暴露新偏差,偏差范围到 U-004 为止已完全摸清(Coder SOUL.md 三处实质性重写),不改判 confirmed,正式收口"},
53 53  
54   - {"id": "U-034", "phase": "PH5", "sub_phase": "PH5-S1", "name": "01-M 扩展:deployment/deploy_health_signal + core_engine 枚举", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["deployment", "deploy_health_signal", "bug_record"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "两张新表建表成功;bug_record 新枚举值向后兼容 U-015/U-018 已有测试"},
  54 + {"id": "U-034", "phase": "PH5", "sub_phase": "PH5-S1", "name": "01-M 扩展:deployment/deploy_health_signal + core_engine 枚举(见 development-plan.md 2026-08-02 实现补充)", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["deployment", "deploy_health_signal", "bug_record"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "两张新表建表成功(共 17 张);bug_record 新枚举值向后兼容 U-015/U-018 已有测试;deployment 状态机用 01-M 1.15 完整 15 值枚举;ux_deployment_active 表达式索引强制全局单例非终态约束;bug_record.py 应用层白名单刻意未跟着放开 core_engine,classification+路由逻辑留给 U-035"},
55 55 {"id": "U-035", "phase": "PH5", "sub_phase": "PH5-S1", "name": "self-target worktree 无条件强制 + core_engine 路由到蓝绿部署", "classification": "Risk Gate", "depends_on": ["U-013", "U-016", "U-034"], "coverage": {"model": [], "view": [], "controller": ["c_org_loop_route"], "process": [], "requirement": ["REQ-03"]}, "done": "self-target 时无条件走 worktree;core_engine 分类的 bug 被路由到 PH5-S2/S3 而非 fast_lane;且改动落地后必须重跑 U-013/REQ-03(worktree 隔离)现有回归用例全部通过(2026-07-30 计划评审追加,确认未破坏 PH4 已建立的隔离保证)"},
56 56 {"id": "U-036", "phase": "PH5", "sub_phase": "PH5-S2", "name": "supervisor/isolation 补齐单测 + Deploy Supervisor 进程骨架", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "四模块各有 test_*.py;Deploy Supervisor 骨架独立进程可启停,不受 Live/Candidate 崩溃影响"},
57 57 {"id": "U-037", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_stage", "classification": "Risk Gate", "depends_on": ["U-034", "U-036"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_stage"], "process": [], "requirement": []}, "done": "能生成 deployment 记录并拉起监听临时端口、连一次性 DB 副本的候选进程"},
... ...
src/schema.py
1 1 """SQLite schema for graphlemon's own structured storage.
2 2  
3 3 U-001 scope: the 15 platform entities from docs/sdd/01-M-模型设计.md §1.0-1.14
4   -(PH0-PH4). `deployment`/`deploy_health_signal` (§1.15/§1.16) are PH5-only and are
5   -deliberately not created here -- see CLAUDE.md "PH5 is not in scope yet" and
6   -development-plan.md U-034.
  4 +(PH0-PH4). `deployment`/`deploy_health_signal` (§1.15/§1.16, added here in U-034,
  5 +2026-08-02) are PH5-only -- 17 tables total as of PH5.
7 6  
8 7 Interpretive conventions used below, where 01-M leaves the physical type implicit:
9 8 - Booleans are stored as INTEGER CHECK(x IN (0, 1)) -- sqlite3 has no native bool type.
... ... @@ -18,12 +17,18 @@ Interpretive conventions used below, where 01-M leaves the physical type implici
18 17 org-node lookup table to reference (planner/coder/validator only exist as entries
19 18 inside org_graph_definition.nodes, a JSON column) -- implemented as a CHECK enum
20 19 instead, matching work_graph_run.org_node / skill.owner_org_node / agent_profile.org_node.
21   -- bug_record.root_cause_category intentionally excludes 'core_engine' for now: that
22   - value is PH5-scoped (05 §5.8) and development-plan.md assigns adding it to U-034,
23   - not U-001 (confirmed with user, 2026-07-30).
  20 +- bug_record.root_cause_category gained 'core_engine' in U-034 (2026-08-02, PH5) --
  21 + 05 §5.8: self-target scenarios (`.claude/agents/*.md` identity file changes, Graph
  22 + Engine scheduler/executor core logic changes, this schema's own changes), routed by
  23 + `c_org_loop_route` to `p_deploy_supervisor_blue_green` instead of fast_lane/full_replan.
24 24 - bug_record and skill have no created_at in 01-M (skill is Git-versioned, so git log
25 25 covers "when"; bug_record has no such backing and this looks like a possible spec
26 26 gap, but adding an undocumented column is out of scope for U-001 -- flagged, not fixed).
  27 +- deployment.previous_commit_hash/previous_instance_dir are "not null" per 01-M 1.15,
  28 + but the very first deployment ever run has no prior `finalized` deployment to point
  29 + at -- that bootstrap case is c_deploy_supervisor_stage's problem (U-037), not this
  30 + schema's: it must supply the currently-running commit/checkout dir as the "previous"
  31 + values on that first call, not leave the schema-level NOT NULL as aspirational.
27 32 """
28 33  
29 34 import sqlite3
... ... @@ -44,6 +49,8 @@ TABLE_NAMES = (
44 49 "execution_event",
45 50 "execution_log_file",
46 51 "log_retention_config",
  52 + "deployment",
  53 + "deploy_health_signal",
47 54 )
48 55  
49 56 SCHEMA_SQL = """
... ... @@ -177,7 +184,7 @@ CREATE TABLE bug_record (
177 184 reported_by TEXT,
178 185 description TEXT NOT NULL,
179 186 repro_steps TEXT,
180   - root_cause_category TEXT CHECK (root_cause_category IN ('planning', 'coder', 'skill_defect')),
  187 + root_cause_category TEXT CHECK (root_cause_category IN ('planning', 'coder', 'skill_defect', 'core_engine')),
181 188 root_cause_detail TEXT,
182 189 related_artifact_ids TEXT,
183 190 suggested_route TEXT CHECK (suggested_route IN ('fast_lane', 'full_replan')),
... ... @@ -268,6 +275,48 @@ CREATE TABLE log_retention_config (
268 275 updated_by TEXT NOT NULL,
269 276 updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
270 277 );
  278 +
  279 +-- 1.15 deployment (PH5, U-034, 2026-08-02 -- see docs/supervisor-auto-rollback-design.md)
  280 +CREATE TABLE deployment (
  281 + deployment_id TEXT PRIMARY KEY,
  282 + git_commit_hash TEXT NOT NULL,
  283 + previous_commit_hash TEXT NOT NULL,
  284 + status TEXT NOT NULL CHECK (status IN (
  285 + 'staged', 'candidate_starting', 'health_check_running', 'health_check_passed',
  286 + 'health_check_failed', 'cutover_in_progress', 'live', 'monitoring',
  287 + 'anomaly_detected', 'rollback_in_progress', 'rolled_back', 'rollback_failed',
  288 + 'blocked_pending_human', 'monitoring_window_clean', 'finalized'
  289 + )),
  290 + candidate_port INTEGER,
  291 + candidate_db_path TEXT,
  292 + started_at TEXT,
  293 + cutover_at TEXT,
  294 + monitoring_closed_at TEXT,
  295 + rollback_at TEXT,
  296 + rollback_reason TEXT,
  297 + previous_instance_dir TEXT NOT NULL
  298 +);
  299 +
  300 +-- only one non-terminal deployment at a time (01-M 1.15's own constraint) --
  301 +-- expression index on a constant, same "global singleton while active" idiom
  302 +-- as a partial unique index scoped by a real column, just with no column to
  303 +-- scope by here (there's exactly one graphlemon self-target, not one per key)
  304 +CREATE UNIQUE INDEX ux_deployment_active ON deployment((1))
  305 + WHERE status NOT IN ('finalized', 'rolled_back', 'blocked_pending_human');
  306 +
  307 +-- 1.16 deploy_health_signal (PH5, U-034, 2026-08-02)
  308 +CREATE TABLE deploy_health_signal (
  309 + signal_id TEXT PRIMARY KEY,
  310 + deployment_id TEXT NOT NULL REFERENCES deployment(deployment_id),
  311 + sampled_at TEXT NOT NULL,
  312 + signal_type TEXT NOT NULL CHECK (signal_type IN (
  313 + 'process_alive', 'http_health', 'canary_task', 'validator_approval_rate'
  314 + )),
  315 + status TEXT NOT NULL CHECK (status IN ('ok', 'degraded', 'fail')),
  316 + detail TEXT
  317 +);
  318 +
  319 +CREATE INDEX idx_deploy_health_signal_deployment_sampled ON deploy_health_signal(deployment_id, sampled_at);
271 320 """
272 321  
273 322  
... ... @@ -289,6 +338,7 @@ def connect(db_path=&quot;:memory:&quot;, check_same_thread=True):
289 338  
290 339  
291 340 def create_schema(conn):
292   - """Create all 15 U-001 tables (+ indexes) on an open connection."""
  341 + """Create all 17 tables (15 from U-001 + deployment/deploy_health_signal
  342 + from U-034, PH5) + indexes on an open connection."""
293 343 conn.executescript(SCHEMA_SQL)
294 344 conn.commit()
... ...
src/test_bug_record.py
... ... @@ -60,9 +60,15 @@ class CreateBugRecordTest(BugRecordTestBase):
60 60 root_cause_detail="d", suggested_route="fast_lane",
61 61 )
62 62  
63   - def test_core_engine_rejected_pre_ph5(self):
64   - # core_engine is PH5-scoped (U-034) -- not accepted yet, matching the
65   - # schema CHECK constraint's own exclusion (see src/schema.py)
  63 + def test_core_engine_rejected_pending_u035(self):
  64 + # U-034 (2026-08-02) widened the SCHEMA's own CHECK constraint to
  65 + # accept core_engine (see src/schema.py), but bug_record.py's own
  66 + # application-layer _VALID_ROOT_CAUSE_CATEGORIES deliberately still
  67 + # excludes it -- nothing yet determines WHEN a bug is core_engine
  68 + # (05 §5.8's file-path-based rule, not the Validator's 3-perspective
  69 + # judge_root_cause, which only ever produces planning/coder/
  70 + # skill_defect); that classification logic + c_org_loop_route's
  71 + # routing to the blue-green flow is U-035's job, not this one's.
66 72 with self.assertRaises(bug_record.InvalidRootCauseCategoryError):
67 73 bug_record.create_bug_record(
68 74 self.conn, task_id="task-1", description="x", root_cause_category="core_engine",
... ...
src/test_schema.py
1   -"""U-001 tests: all 15 tables build, and the constraints named in the Unit's
  1 +"""U-001 tests: all tables build, and the constraints named in the Unit's
2 2 Done criterion (unique / foreign key / enum) are actually enforced.
  3 +U-034 (PH5, 2026-08-02) added `deployment`/`deploy_health_signal` (17 tables
  4 +total) and widened `bug_record.root_cause_category` to accept `core_engine`.
3 5  
4 6 Run: python3 -m unittest src.test_schema -v
5 7 """
... ... @@ -18,13 +20,13 @@ class SchemaCreationTest(unittest.TestCase):
18 20 def tearDown(self):
19 21 self.conn.close()
20 22  
21   - def test_all_15_tables_created(self):
  23 + def test_all_17_tables_created(self):
22 24 rows = self.conn.execute(
23 25 "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"
24 26 ).fetchall()
25 27 actual = {row[0] for row in rows}
26 28 self.assertEqual(actual, set(schema.TABLE_NAMES))
27   - self.assertEqual(len(schema.TABLE_NAMES), 15)
  29 + self.assertEqual(len(schema.TABLE_NAMES), 17)
28 30  
29 31 def test_schema_is_idempotent_to_reopen_on_disk(self):
30 32 # not required by the Done criterion, but a schema that only works once
... ... @@ -234,9 +236,10 @@ class ExecutionLogFileUniqueConstraintTest(ConstraintTestBase):
234 236  
235 237  
236 238 class BugRecordRootCauseCategoryTest(ConstraintTestBase):
237   - """core_engine is PH5-scoped (U-034); U-001 deliberately does not accept it yet
238   - (confirmed with user, 2026-07-30) -- this test locks that decision in so a
239   - future edit can't silently widen the enum back out ahead of U-034."""
  239 + """core_engine was PH5-scoped and deliberately excluded pre-U-034
  240 + (confirmed with user, 2026-07-30); U-034 (2026-08-02) widened the enum
  241 + to accept it -- 05 §5.8's self-target routing needs it as a real,
  242 + storable category, not just a documented intention."""
240 243  
241 244 def _insert_bug(self, root_cause_category):
242 245 self.conn.execute(
... ... @@ -245,8 +248,8 @@ class BugRecordRootCauseCategoryTest(ConstraintTestBase):
245 248 (root_cause_category,),
246 249 )
247 250  
248   - def test_pre_ph5_categories_accepted(self):
249   - for category in ("planning", "coder", "skill_defect"):
  251 + def test_all_four_categories_accepted(self):
  252 + for category in ("planning", "coder", "skill_defect", "core_engine"):
250 253 with self.subTest(category=category):
251 254 conn = schema.connect(":memory:")
252 255 schema.create_schema(conn)
... ... @@ -258,9 +261,121 @@ class BugRecordRootCauseCategoryTest(ConstraintTestBase):
258 261 conn.commit()
259 262 conn.close()
260 263  
261   - def test_core_engine_rejected_before_u034(self):
  264 + def test_unknown_category_still_rejected(self):
262 265 with self.assertRaises(sqlite3.IntegrityError):
263   - self._insert_bug("core_engine")
  266 + self._insert_bug("not_a_real_category")
  267 +
  268 +
  269 +class DeploymentEnumConstraintTest(ConstraintTestBase):
  270 + def _insert_deployment(self, deployment_id, status):
  271 + self.conn.execute(
  272 + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, "
  273 + "previous_instance_dir) VALUES (?, 'sha-new', 'sha-old', ?, '/tmp/prev-instance')",
  274 + (deployment_id, status),
  275 + )
  276 +
  277 + def test_every_state_machine_status_accepted(self):
  278 + statuses = (
  279 + "staged", "candidate_starting", "health_check_running", "health_check_passed",
  280 + "health_check_failed", "cutover_in_progress", "live", "monitoring",
  281 + "anomaly_detected", "rollback_in_progress", "rolled_back", "rollback_failed",
  282 + "blocked_pending_human", "monitoring_window_clean", "finalized",
  283 + )
  284 + for status in statuses:
  285 + with self.subTest(status=status):
  286 + conn = schema.connect(":memory:")
  287 + schema.create_schema(conn)
  288 + conn.execute(
  289 + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, "
  290 + "previous_instance_dir) VALUES ('d1', 'sha-new', 'sha-old', ?, '/tmp/prev-instance')",
  291 + (status,),
  292 + )
  293 + conn.commit()
  294 + conn.close()
  295 +
  296 + def test_invalid_status_rejected(self):
  297 + with self.assertRaises(sqlite3.IntegrityError):
  298 + self._insert_deployment("d1", "not_a_real_status")
  299 +
  300 +
  301 +class DeploymentActiveUniqueIndexTest(ConstraintTestBase):
  302 + """01-M 1.15: only one non-terminal deployment (not finalized/rolled_back/
  303 + blocked_pending_human) at a time -- concurrent self-modifying deploys
  304 + would corrupt the single Live/previous_instance_dir bookkeeping."""
  305 +
  306 + def _insert_deployment(self, deployment_id, status):
  307 + self.conn.execute(
  308 + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, "
  309 + "previous_instance_dir) VALUES (?, 'sha-new', 'sha-old', ?, '/tmp/prev-instance')",
  310 + (deployment_id, status),
  311 + )
  312 +
  313 + def test_second_active_deployment_rejected(self):
  314 + self._insert_deployment("d1", "staged")
  315 + self.conn.commit()
  316 + with self.assertRaises(sqlite3.IntegrityError):
  317 + self._insert_deployment("d2", "candidate_starting")
  318 +
  319 + def test_second_deployment_allowed_once_first_is_terminal(self):
  320 + for terminal_status in ("finalized", "rolled_back", "blocked_pending_human"):
  321 + with self.subTest(terminal_status=terminal_status):
  322 + conn = schema.connect(":memory:")
  323 + schema.create_schema(conn)
  324 + conn.execute(
  325 + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, "
  326 + "previous_instance_dir) VALUES ('d1', 'sha-new', 'sha-old', ?, '/tmp/prev-instance')",
  327 + (terminal_status,),
  328 + )
  329 + conn.commit()
  330 + conn.execute(
  331 + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, "
  332 + "previous_instance_dir) VALUES ('d2', 'sha-newer', 'sha-new', 'staged', '/tmp/prev-instance-2')"
  333 + )
  334 + conn.commit()
  335 + count = conn.execute("SELECT COUNT(*) FROM deployment").fetchone()[0]
  336 + conn.close()
  337 + self.assertEqual(count, 2)
  338 +
  339 +
  340 +class DeployHealthSignalTest(ConstraintTestBase):
  341 + def setUp(self):
  342 + super().setUp()
  343 + self.conn.execute(
  344 + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, "
  345 + "previous_instance_dir) VALUES ('d1', 'sha-new', 'sha-old', 'health_check_running', "
  346 + "'/tmp/prev-instance')"
  347 + )
  348 + self.conn.commit()
  349 +
  350 + def test_valid_signal_accepted(self):
  351 + self.conn.execute(
  352 + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status, detail) "
  353 + "VALUES ('sig-1', 'd1', '2026-08-02T00:00:00Z', 'process_alive', 'ok', 'pid 1234 alive')"
  354 + )
  355 + self.conn.commit()
  356 + row = self.conn.execute("SELECT status FROM deploy_health_signal WHERE signal_id = 'sig-1'").fetchone()
  357 + self.assertEqual(row[0], "ok")
  358 +
  359 + def test_invalid_signal_type_rejected(self):
  360 + with self.assertRaises(sqlite3.IntegrityError):
  361 + self.conn.execute(
  362 + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status) "
  363 + "VALUES ('sig-1', 'd1', '2026-08-02T00:00:00Z', 'not_a_real_signal', 'ok')"
  364 + )
  365 +
  366 + def test_invalid_status_rejected(self):
  367 + with self.assertRaises(sqlite3.IntegrityError):
  368 + self.conn.execute(
  369 + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status) "
  370 + "VALUES ('sig-1', 'd1', '2026-08-02T00:00:00Z', 'process_alive', 'not_a_real_status')"
  371 + )
  372 +
  373 + def test_rejects_unknown_deployment_id(self):
  374 + with self.assertRaises(sqlite3.IntegrityError):
  375 + self.conn.execute(
  376 + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status) "
  377 + "VALUES ('sig-1', 'no-such-deployment', '2026-08-02T00:00:00Z', 'process_alive', 'ok')"
  378 + )
264 379  
265 380  
266 381 if __name__ == "__main__":
... ...