fix(db): add missing link_type, FK constraints, and partial index per spec DDL
CI / push-validation (pull_request) Successful in 28s
CI / helm (pull_request) Successful in 36s
CI / build (pull_request) Successful in 4m1s
CI / lint (pull_request) Successful in 4m14s
CI / quality (pull_request) Successful in 4m33s
CI / typecheck (pull_request) Successful in 4m54s
CI / security (pull_request) Successful in 4m55s
CI / unit_tests (pull_request) Successful in 7m48s
CI / e2e_tests (pull_request) Successful in 7m51s
CI / integration_tests (pull_request) Successful in 7m59s
CI / docker (pull_request) Successful in 2m7s
CI / coverage (pull_request) Successful in 15m56s
CI / status-check (pull_request) Successful in 3s
CI / benchmark-publish (push) Waiting to run
CI / benchmark-regression (push) Waiting to run
CI / push-validation (push) Successful in 22s
CI / helm (push) Successful in 27s
CI / build (push) Successful in 3m47s
CI / lint (push) Successful in 3m56s
CI / quality (push) Successful in 4m21s
CI / typecheck (push) Successful in 4m34s
CI / security (push) Successful in 4m44s
CI / e2e_tests (push) Successful in 6m43s
CI / unit_tests (push) Successful in 7m19s
CI / integration_tests (push) Successful in 7m44s
CI / docker (push) Successful in 1m29s
CI / coverage (push) Successful in 15m57s
CI / status-check (push) Successful in 4s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Failing after 1h9m42s

Align resource_links, checkpoint_metadata, and decisions schema behavior with the spec DDL. Add SQLite runtime trigger guards for checkpoint foreign-key semantics and migration-focused Behave/Robot checks that orphan checkpoint references are rejected.

ISSUES CLOSED: #922
This commit was merged in pull request #1167.
This commit is contained in:
CoreRasurae
2026-03-26 23:37:11 +00:00
committed by Forgejo
parent 7f29874156
commit f8233000cc
10 changed files with 2254 additions and 13 deletions
+88
View File
@@ -11,6 +11,16 @@ Feature: Database migration lifecycle
Then the database should have all expected tables Then the database should have all expected tables
And the current revision should be the head revision And the current revision should be the head revision
Scenario: Spec-parity schema details are present after migration
Given all migrations have been applied
Then the "resource_links" table should include "link_type" with default "contains"
And checkpoint_metadata should enforce decision and resource foreign keys
And checkpoint_metadata foreign keys should reject orphan references
And checkpoint_metadata should reject orphan decision_id independently
And checkpoint_metadata should reject orphan resource_id independently
And checkpoint_metadata should accept valid decision and resource references
And the "decisions" table should have partial index "idx_decisions_superseded" on "superseded_by"
Scenario: Roll back all migrations to base Scenario: Roll back all migrations to base
Given all migrations have been applied Given all migrations have been applied
When I downgrade all migrations to base When I downgrade all migrations to base
@@ -45,6 +55,84 @@ Feature: Database migration lifecycle
Then the database should be stamped with alembic_version Then the database should be stamped with alembic_version
And the database revision should be at head And the database revision should be at head
Scenario: Migration orphan cleanup nullifies stale checkpoint references
Given migrations applied up to "m4_003_plan_env_columns"
And checkpoint_metadata contains orphan decision and resource references
When I upgrade to the next migration revision
Then the orphan decision_id values should be NULL
And the orphan resource_id values should be NULL
And checkpoint_metadata should enforce decision and resource foreign keys
Scenario: resource_links.link_type accepts spec-defined non-default values
Given all migrations have been applied
Then resource_links should accept link_type "references"
And resource_links should accept link_type "derived_from"
Scenario: resource_links.link_type rejects NULL values
Given all migrations have been applied
Then resource_links should reject NULL link_type
Scenario: resource_links.link_type rejects invalid values
Given all migrations have been applied
Then resource_links should reject link_type "invalid_type"
Scenario: Deleting a decision sets checkpoint decision_id to NULL
Given all migrations have been applied
And a checkpoint references a valid decision
When the referenced decision is deleted
Then the checkpoint decision_id should be NULL
Scenario: Deleting a resource sets checkpoint resource_id to NULL
Given all migrations have been applied
And a checkpoint references a valid resource
When the referenced resource is deleted
Then the checkpoint resource_id should be NULL
Scenario: Spec-parity m4_004 downgrade removes added schema objects
Given all migrations have been applied
When I downgrade to revision "m4_003_plan_env_columns"
Then the "resource_links" table should not include "link_type"
And the "decisions" table should not have index "idx_decisions_superseded"
And checkpoint_metadata should not have foreign key "fk_checkpoint_metadata_decision"
And checkpoint_metadata should not have foreign key "fk_checkpoint_metadata_resource"
And checkpoint_metadata should not have SQLite triggers for FK enforcement
Scenario: Migration idempotency: existing link_type column gains CHECK constraint
Given migrations applied up to "m4_003_plan_env_columns"
And resource_links already has a link_type column without CHECK constraint
When I upgrade to the next migration revision
Then the "resource_links" table should include "link_type" with default "contains"
And resource_links should reject link_type "invalid_type"
Scenario: Migration idempotency: existing link_type column with wrong default is corrected
Given migrations applied up to "m4_003_plan_env_columns"
And resource_links already has a link_type column with a non-contains default
When I upgrade to the next migration revision
Then the "resource_links" table should include "link_type" with default "contains"
And resource_links should reject link_type "invalid_type"
Scenario: Positive checkpoint FK test verifies persisted row
Given all migrations have been applied
And valid decision and resource rows exist
When I insert a checkpoint with valid FK references
Then the checkpoint row should be persisted in the database
Scenario: UPDATE trigger rejects orphan decision_id on checkpoint_metadata
Given all migrations have been applied
And a checkpoint exists with valid FK references
When I update the checkpoint decision_id to a non-existent value
Then the update should be rejected with an integrity error
Scenario: UPDATE trigger rejects orphan resource_id on checkpoint_metadata
Given all migrations have been applied
And a checkpoint exists with valid FK references
When I update the checkpoint resource_id to a non-existent value
Then the update should be rejected with an integrity error
Scenario: resource_links.link_type rejects empty string values
Given all migrations have been applied
Then resource_links should reject empty string link_type
Scenario: init_database creates a valid schema Scenario: init_database creates a valid schema
When I call init_database with an in-memory URL When I call init_database with an in-memory URL
Then the resulting engine should have a valid schema Then the resulting engine should have a valid schema
@@ -1,4 +1,4 @@
"""Step definitions for db_migration_lifecycle.feature. """Step definitions for db_migration_lifecycle.feature — core lifecycle.
Exercises the full Alembic migration lifecycle: forward application, Exercises the full Alembic migration lifecycle: forward application,
rollback, round-trip testing, CLI wrappers, stamp logic, and the rollback, round-trip testing, CLI wrappers, stamp logic, and the
@@ -13,6 +13,7 @@ from typing import Any
from unittest.mock import patch from unittest.mock import patch
from behave import given, then, when from behave import given, then, when
from sqlalchemy import inspect as sa_inspect
def _create_temp_db_path() -> str: def _create_temp_db_path() -> str:
@@ -75,8 +76,6 @@ _EXPECTED_TABLES = {
def _get_table_names(engine: Any) -> set[str]: def _get_table_names(engine: Any) -> set[str]:
from sqlalchemy import inspect as sa_inspect
inspector = sa_inspect(engine) inspector = sa_inspect(engine)
return set(inspector.get_table_names()) return set(inspector.get_table_names())
+457
View File
@@ -0,0 +1,457 @@
"""Step definitions for db_migration_lifecycle.feature — cascade and persistence.
``ondelete="SET NULL"`` cascade verification for checkpoint_metadata foreign
keys, positive FK persistence tests, and trigger removal verification on
downgrade.
"""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from features.steps.db_schema_parity_steps import (
_ensure_test_action_and_plan,
_insert_test_resource,
)
# ---------------------------------------------------------------------------
# Given/When/Then — positive FK persistence test
# ---------------------------------------------------------------------------
@given("valid decision and resource rows exist")
def step_valid_decision_and_resource_exist(context: Any) -> None:
action_name = "local/test-action-persist"
plan_id = "01ARZ3NDEKTSV4RRFFQ69G5FE0"
decision_id = "01ARZ3NDEKTSV4RRFFQ69G5FE1"
resource_id = "01ARZ3NDEKTSV4RRFFQ69G5FE2"
with context.engine.begin() as conn:
_ensure_test_action_and_plan(conn, action_name, plan_id)
conn.execute(
text(
"""
INSERT INTO decisions (
decision_id, plan_id, decision_type, question,
chosen_option, context_snapshot_json, sequence_number,
created_at
) VALUES (
:decision_id, :plan_id, :decision_type, :question,
:chosen_option, :context_snapshot_json, :sequence_number,
:created_at
)
"""
),
{
"decision_id": decision_id,
"plan_id": plan_id,
"decision_type": "strategy_choice",
"question": "test question",
"chosen_option": "test option",
"context_snapshot_json": "{}",
"sequence_number": 1,
"created_at": "2026-01-01T00:00:00",
},
)
_insert_test_resource(conn, resource_id)
context.persist_plan_id = plan_id
context.persist_decision_id = decision_id
context.persist_resource_id = resource_id
context.persist_checkpoint_id = "01ARZ3NDEKTSV4RRFFQ69G5FE3"
@when("I insert a checkpoint with valid FK references")
def step_insert_checkpoint_valid_fk(context: Any) -> None:
with context.engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO checkpoint_metadata (
checkpoint_id, plan_id, decision_id,
checkpoint_type, resource_id, sandbox_ref,
filesystem_path, created_at
) VALUES (
:checkpoint_id, :plan_id, :decision_id,
:checkpoint_type, :resource_id, :sandbox_ref,
:filesystem_path, :created_at
)
"""
),
{
"checkpoint_id": context.persist_checkpoint_id,
"plan_id": context.persist_plan_id,
"decision_id": context.persist_decision_id,
"checkpoint_type": "manual",
"resource_id": context.persist_resource_id,
"sandbox_ref": "test-ref",
"filesystem_path": "",
"created_at": "2026-01-01T00:00:00",
},
)
@then("the checkpoint row should be persisted in the database")
def step_checkpoint_persisted(context: Any) -> None:
with context.engine.connect() as conn:
row = conn.execute(
text(
"SELECT checkpoint_id, decision_id, resource_id "
"FROM checkpoint_metadata WHERE checkpoint_id = :cid"
),
{"cid": context.persist_checkpoint_id},
).fetchone()
assert row is not None, (
f"Checkpoint {context.persist_checkpoint_id!r} was not persisted"
)
assert row[0] == context.persist_checkpoint_id
assert row[1] == context.persist_decision_id, (
f"Expected decision_id {context.persist_decision_id!r}, got {row[1]!r}"
)
assert row[2] == context.persist_resource_id, (
f"Expected resource_id {context.persist_resource_id!r}, got {row[2]!r}"
)
# ---------------------------------------------------------------------------
# Given/When/Then — UPDATE trigger rejection
# ---------------------------------------------------------------------------
@given("a checkpoint exists with valid FK references")
def step_checkpoint_with_valid_fk_refs(context: Any) -> None:
action_name = "local/test-action-upd-trg"
plan_id = "01ARZ3NDEKTSV4RRFFQ69G5FH0"
decision_id = "01ARZ3NDEKTSV4RRFFQ69G5FH1"
resource_id = "01ARZ3NDEKTSV4RRFFQ69G5FH2"
checkpoint_id = "01ARZ3NDEKTSV4RRFFQ69G5FH3"
with context.engine.begin() as conn:
_ensure_test_action_and_plan(conn, action_name, plan_id)
conn.execute(
text(
"""
INSERT INTO decisions (
decision_id, plan_id, decision_type, question,
chosen_option, context_snapshot_json, sequence_number,
created_at
) VALUES (
:decision_id, :plan_id, :decision_type, :question,
:chosen_option, :context_snapshot_json, :sequence_number,
:created_at
)
"""
),
{
"decision_id": decision_id,
"plan_id": plan_id,
"decision_type": "strategy_choice",
"question": "test question",
"chosen_option": "test option",
"context_snapshot_json": "{}",
"sequence_number": 1,
"created_at": "2026-01-01T00:00:00",
},
)
_insert_test_resource(conn, resource_id)
conn.execute(
text(
"""
INSERT INTO checkpoint_metadata (
checkpoint_id, plan_id, decision_id,
checkpoint_type, resource_id, sandbox_ref,
filesystem_path, created_at
) VALUES (
:checkpoint_id, :plan_id, :decision_id,
:checkpoint_type, :resource_id, :sandbox_ref,
:filesystem_path, :created_at
)
"""
),
{
"checkpoint_id": checkpoint_id,
"plan_id": plan_id,
"decision_id": decision_id,
"checkpoint_type": "manual",
"resource_id": resource_id,
"sandbox_ref": "test-ref",
"filesystem_path": "",
"created_at": "2026-01-01T00:00:00",
},
)
context.update_trg_checkpoint_id = checkpoint_id
@when("I update the checkpoint decision_id to a non-existent value")
def step_update_checkpoint_decision_orphan(context: Any) -> None:
try:
with context.engine.begin() as conn:
conn.execute(
text(
"UPDATE checkpoint_metadata "
"SET decision_id = :orphan "
"WHERE checkpoint_id = :cid"
),
{
"orphan": "NONEXISTENT_DECISION_ID_UPD",
"cid": context.update_trg_checkpoint_id,
},
)
except IntegrityError:
context.update_trigger_rejected = True
return
context.update_trigger_rejected = False
@when("I update the checkpoint resource_id to a non-existent value")
def step_update_checkpoint_resource_orphan(context: Any) -> None:
try:
with context.engine.begin() as conn:
conn.execute(
text(
"UPDATE checkpoint_metadata "
"SET resource_id = :orphan "
"WHERE checkpoint_id = :cid"
),
{
"orphan": "NONEXISTENT_RESOURCE_ID_UPD",
"cid": context.update_trg_checkpoint_id,
},
)
except IntegrityError:
context.update_trigger_rejected = True
return
context.update_trigger_rejected = False
@then("the update should be rejected with an integrity error")
def step_update_rejected(context: Any) -> None:
assert context.update_trigger_rejected, (
"Expected UPDATE to be rejected by FK trigger, but it was accepted"
)
# ---------------------------------------------------------------------------
# Helper — enable FK enforcement on the pooled DBAPI connection
# ---------------------------------------------------------------------------
def _enable_fk_enforcement(engine: Any) -> None:
"""Enable PRAGMA foreign_keys on the engine's pooled DBAPI connection.
For in-memory SQLite databases, SQLAlchemy uses ``StaticPool`` which
shares a single underlying DBAPI connection across all pool checkouts.
Calling ``raw_connection()`` returns a wrapper around that shared
connection; ``close()`` returns it to the pool without closing the
DBAPI connection. The PRAGMA therefore persists for all subsequent
``engine.begin()`` / ``engine.connect()`` calls.
This matches the codebase convention of setting PRAGMA foreign_keys
at the connection level (e.g. ``resource_repository_steps.py``,
``plan_lifecycle_persistence_steps.py``). The ``event.listens_for``
variant used in engine-creation contexts is not applicable here
because the engine's connections have already been established during
migration; the ``"connect"`` event would not fire for existing
pooled connections.
"""
raw_conn = engine.raw_connection()
try:
cursor = raw_conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
finally:
raw_conn.close()
# ---------------------------------------------------------------------------
# Given/When/Then — ondelete="SET NULL" cascade (decision)
# ---------------------------------------------------------------------------
@given("a checkpoint references a valid decision")
def step_checkpoint_references_decision(context: Any) -> None:
action_name = "local/test-action-set-null-d"
plan_id = "01ARZ3NDEKTSV4RRFFQ69G5FG0"
decision_id = "01ARZ3NDEKTSV4RRFFQ69G5FG1"
checkpoint_id = "01ARZ3NDEKTSV4RRFFQ69G5FG2"
with context.engine.begin() as conn:
_ensure_test_action_and_plan(conn, action_name, plan_id)
conn.execute(
text(
"""
INSERT INTO decisions (
decision_id, plan_id, decision_type, question,
chosen_option, context_snapshot_json, sequence_number,
created_at
) VALUES (
:decision_id, :plan_id, :decision_type, :question,
:chosen_option, :context_snapshot_json, :sequence_number,
:created_at
)
"""
),
{
"decision_id": decision_id,
"plan_id": plan_id,
"decision_type": "strategy_choice",
"question": "test question",
"chosen_option": "test option",
"context_snapshot_json": "{}",
"sequence_number": 1,
"created_at": "2026-01-01T00:00:00",
},
)
conn.execute(
text(
"""
INSERT INTO checkpoint_metadata (
checkpoint_id, plan_id, decision_id,
checkpoint_type, sandbox_ref,
filesystem_path, created_at
) VALUES (
:checkpoint_id, :plan_id, :decision_id,
:checkpoint_type, :sandbox_ref,
:filesystem_path, :created_at
)
"""
),
{
"checkpoint_id": checkpoint_id,
"plan_id": plan_id,
"decision_id": decision_id,
"checkpoint_type": "manual",
"sandbox_ref": "test-ref",
"filesystem_path": "",
"created_at": "2026-01-01T00:00:00",
},
)
context.set_null_decision_id = decision_id
context.set_null_checkpoint_id_d = checkpoint_id
context.set_null_plan_id_d = plan_id
@when("the referenced decision is deleted")
def step_delete_referenced_decision(context: Any) -> None:
# Enable FK enforcement via the established codebase pattern
# (event listener on "connect") so that ondelete="SET NULL" is honoured.
_enable_fk_enforcement(context.engine)
with context.engine.begin() as conn:
conn.execute(
text("DELETE FROM decisions WHERE decision_id = :did"),
{"did": context.set_null_decision_id},
)
@then("the checkpoint decision_id should be NULL")
def step_checkpoint_decision_id_null(context: Any) -> None:
with context.engine.connect() as conn:
row = conn.execute(
text(
"SELECT decision_id FROM checkpoint_metadata WHERE checkpoint_id = :cid"
),
{"cid": context.set_null_checkpoint_id_d},
).fetchone()
assert row is not None, (
f"Checkpoint {context.set_null_checkpoint_id_d!r} missing after decision deletion"
)
assert row[0] is None, (
f"Expected checkpoint decision_id to be NULL after parent deletion, "
f"got {row[0]!r}"
)
# ---------------------------------------------------------------------------
# Given/When/Then — ondelete="SET NULL" cascade (resource)
# ---------------------------------------------------------------------------
@given("a checkpoint references a valid resource")
def step_checkpoint_references_resource(context: Any) -> None:
action_name = "local/test-action-set-null-r"
plan_id = "01ARZ3NDEKTSV4RRFFQ69G5FG3"
resource_id = "01ARZ3NDEKTSV4RRFFQ69G5FG4"
checkpoint_id = "01ARZ3NDEKTSV4RRFFQ69G5FG5"
with context.engine.begin() as conn:
_ensure_test_action_and_plan(conn, action_name, plan_id)
_insert_test_resource(conn, resource_id)
conn.execute(
text(
"""
INSERT INTO checkpoint_metadata (
checkpoint_id, plan_id, resource_id,
checkpoint_type, sandbox_ref,
filesystem_path, created_at
) VALUES (
:checkpoint_id, :plan_id, :resource_id,
:checkpoint_type, :sandbox_ref,
:filesystem_path, :created_at
)
"""
),
{
"checkpoint_id": checkpoint_id,
"plan_id": plan_id,
"resource_id": resource_id,
"checkpoint_type": "manual",
"sandbox_ref": "test-ref",
"filesystem_path": "",
"created_at": "2026-01-01T00:00:00",
},
)
context.set_null_resource_id = resource_id
context.set_null_checkpoint_id_r = checkpoint_id
@when("the referenced resource is deleted")
def step_delete_referenced_resource(context: Any) -> None:
# Enable FK enforcement via the established codebase pattern
# (event listener on "connect") so that ondelete="SET NULL" is honoured.
_enable_fk_enforcement(context.engine)
with context.engine.begin() as conn:
conn.execute(
text("DELETE FROM resources WHERE resource_id = :rid"),
{"rid": context.set_null_resource_id},
)
@then("the checkpoint resource_id should be NULL")
def step_checkpoint_resource_id_null(context: Any) -> None:
with context.engine.connect() as conn:
row = conn.execute(
text(
"SELECT resource_id FROM checkpoint_metadata WHERE checkpoint_id = :cid"
),
{"cid": context.set_null_checkpoint_id_r},
).fetchone()
assert row is not None, (
f"Checkpoint {context.set_null_checkpoint_id_r!r} missing after resource deletion"
)
assert row[0] is None, (
f"Expected checkpoint resource_id to be NULL after parent deletion, "
f"got {row[0]!r}"
)
+432
View File
@@ -0,0 +1,432 @@
"""Step definitions for db_migration_lifecycle.feature — link type and migration.
Link-type acceptance/rejection, migration idempotency (else-branch), orphan
cleanup during migration, and downgrade verification for the m4_004 schema
parity migration.
"""
from __future__ import annotations
from typing import Any
from alembic import command
from behave import given, then, when
from sqlalchemy import inspect as sa_inspect
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from cleveragents.infrastructure.database.migration_runner import MigrationRunner
from features.steps.db_schema_parity_steps import _insert_test_resource
# ---------------------------------------------------------------------------
# Given — migration idempotency (link_type pre-exists)
# ---------------------------------------------------------------------------
@given("resource_links already has a link_type column without CHECK constraint")
def step_add_link_type_without_check(context: Any) -> None:
"""Add a bare link_type column so the migration else-branch is exercised."""
with context.engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE resource_links "
"ADD COLUMN link_type TEXT DEFAULT 'contains'"
)
)
@given("resource_links already has a link_type column with a non-contains default")
def step_add_link_type_wrong_default(context: Any) -> None:
"""Add a link_type column with wrong default so the default-fix path runs."""
with context.engine.begin() as conn:
conn.execute(
text("ALTER TABLE resource_links ADD COLUMN link_type TEXT DEFAULT 'other'")
)
# ---------------------------------------------------------------------------
# Given/When/Then — orphan cleanup during migration
# ---------------------------------------------------------------------------
@given('migrations applied up to "{revision}"')
def step_migrations_up_to(context: Any, revision: str) -> None:
runner = MigrationRunner(context.db_url)
with context.engine.connect() as conn:
runner.alembic_cfg.attributes["connection"] = conn
try:
command.upgrade(runner.alembic_cfg, revision)
conn.commit()
finally:
runner.alembic_cfg.attributes.pop("connection", None)
context.runner = runner
@given("checkpoint_metadata contains orphan decision and resource references")
def step_insert_orphan_checkpoint_refs(context: Any) -> None:
action_name = "local/orphan-cleanup-action"
plan_id = "01ARZ3NDEKTSV4RRFFQ69G5FD0"
checkpoint_id_1 = "01ARZ3NDEKTSV4RRFFQ69G5FD1"
checkpoint_id_2 = "01ARZ3NDEKTSV4RRFFQ69G5FD2"
with context.engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO actions (
namespaced_name, namespace, name, description,
definition_of_done, strategy_actor, execution_actor,
created_at, updated_at
) VALUES (
:namespaced_name, :namespace, :name, :description,
:definition_of_done, :strategy_actor, :execution_actor,
:created_at, :updated_at
)
"""
),
{
"namespaced_name": action_name,
"namespace": "local",
"name": "orphan-cleanup-action",
"description": "test action",
"definition_of_done": "test dod",
"strategy_actor": "local/strategy",
"execution_actor": "local/execution",
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:00",
},
)
# Detect whether root_plan_id column exists (added by
# m8_001_align_plans_schema which may not yet be applied when
# this step runs at the m4_003 migration state).
plan_columns = {
col["name"] for col in sa_inspect(context.engine).get_columns("v3_plans")
}
plan_cols = (
"plan_id, action_name, namespaced_name, namespace,"
" description, created_at, updated_at"
)
plan_vals = (
":plan_id, :action_name, :namespaced_name, :namespace,"
" :description, :created_at, :updated_at"
)
plan_params: dict[str, str] = {
"plan_id": plan_id,
"action_name": action_name,
"namespaced_name": "local/orphan-plan",
"namespace": "local",
"description": "test plan for orphan cleanup",
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:00",
}
if "root_plan_id" in plan_columns:
plan_cols = (
"plan_id, root_plan_id, action_name,"
" namespaced_name, namespace,"
" description, created_at, updated_at"
)
plan_vals = (
":plan_id, :root_plan_id, :action_name,"
" :namespaced_name, :namespace,"
" :description, :created_at, :updated_at"
)
plan_params["root_plan_id"] = plan_id
conn.execute(
text(f"INSERT INTO v3_plans ({plan_cols}) VALUES ({plan_vals})"),
plan_params,
)
# Checkpoint with orphan decision_id
conn.execute(
text(
"""
INSERT INTO checkpoint_metadata (
checkpoint_id, plan_id, decision_id,
checkpoint_type, sandbox_ref,
filesystem_path, created_at
) VALUES (
:checkpoint_id, :plan_id, :decision_id,
:checkpoint_type, :sandbox_ref,
:filesystem_path, :created_at
)
"""
),
{
"checkpoint_id": checkpoint_id_1,
"plan_id": plan_id,
"decision_id": "ORPHAN_DECISION_ID_NONEXIST",
"checkpoint_type": "manual",
"sandbox_ref": "test-ref",
"filesystem_path": "",
"created_at": "2026-01-01T00:00:00",
},
)
# Checkpoint with orphan resource_id
conn.execute(
text(
"""
INSERT INTO checkpoint_metadata (
checkpoint_id, plan_id, resource_id,
checkpoint_type, sandbox_ref,
filesystem_path, created_at
) VALUES (
:checkpoint_id, :plan_id, :resource_id,
:checkpoint_type, :sandbox_ref,
:filesystem_path, :created_at
)
"""
),
{
"checkpoint_id": checkpoint_id_2,
"plan_id": plan_id,
"resource_id": "ORPHAN_RESOURCE_ID_NONEXIST",
"checkpoint_type": "manual",
"sandbox_ref": "test-ref",
"filesystem_path": "",
"created_at": "2026-01-01T00:00:00",
},
)
context.orphan_plan_id = plan_id
context.orphan_checkpoint_id_1 = checkpoint_id_1
context.orphan_checkpoint_id_2 = checkpoint_id_2
@when("I upgrade to the next migration revision")
def step_upgrade_one_revision(context: Any) -> None:
runner = context.runner
with context.engine.connect() as conn:
runner.alembic_cfg.attributes["connection"] = conn
try:
# Target m4_004 explicitly because m4_003 has multiple
# child branches (m4_004, m5_001, m8_001_*) and "+1"
# would cause an "Ambiguous walk" error.
command.upgrade(
runner.alembic_cfg,
"m4_004_schema_parity_resource_decision_checkpoint",
)
conn.commit()
finally:
runner.alembic_cfg.attributes.pop("connection", None)
@then("the orphan decision_id values should be NULL")
def step_orphan_decision_id_null(context: Any) -> None:
with context.engine.connect() as conn:
row = conn.execute(
text(
"SELECT decision_id FROM checkpoint_metadata WHERE checkpoint_id = :cid"
),
{"cid": context.orphan_checkpoint_id_1},
).fetchone()
assert row is not None, (
f"Checkpoint {context.orphan_checkpoint_id_1!r} missing after migration"
)
assert row[0] is None, f"Expected orphan decision_id to be NULL, got {row[0]!r}"
@then("the orphan resource_id values should be NULL")
def step_orphan_resource_id_null(context: Any) -> None:
with context.engine.connect() as conn:
row = conn.execute(
text(
"SELECT resource_id FROM checkpoint_metadata WHERE checkpoint_id = :cid"
),
{"cid": context.orphan_checkpoint_id_2},
).fetchone()
assert row is not None, (
f"Checkpoint {context.orphan_checkpoint_id_2!r} missing after migration"
)
assert row[0] is None, f"Expected orphan resource_id to be NULL, got {row[0]!r}"
# ---------------------------------------------------------------------------
# Then — link_type acceptance/rejection
# ---------------------------------------------------------------------------
@then('resource_links should accept link_type "{link_type_value}"')
def step_resource_links_accepts_link_type(context: Any, link_type_value: str) -> None:
with context.engine.begin() as conn:
parent_id = f"01LINKTYPE_{link_type_value.upper()[:6]}P"
child_id = f"01LINKTYPE_{link_type_value.upper()[:6]}C"
for rid in (parent_id, child_id):
_insert_test_resource(conn, rid)
conn.execute(
text(
"""
INSERT INTO resource_links (parent_id, child_id, link_type, created_at)
VALUES (:parent_id, :child_id, :link_type, :created_at)
"""
),
{
"parent_id": parent_id,
"child_id": child_id,
"link_type": link_type_value,
"created_at": "2026-01-01T00:00:00",
},
)
@then("resource_links should reject NULL link_type")
def step_resource_links_rejects_null_link_type(context: Any) -> None:
with context.engine.begin() as conn:
parent_id = "01LINKNULLP_TESTPARENT"
child_id = "01LINKNULLC_TESTCHILD0"
for rid in (parent_id, child_id):
_insert_test_resource(conn, rid)
try:
conn.execute(
text(
"""
INSERT INTO resource_links (parent_id, child_id, link_type, created_at)
VALUES (:parent_id, :child_id, NULL, :created_at)
"""
),
{
"parent_id": parent_id,
"child_id": child_id,
"created_at": "2026-01-01T00:00:00",
},
)
except IntegrityError:
return
raise AssertionError("resource_links accepted NULL link_type")
@then('resource_links should reject link_type "{link_type_value}"')
def step_resource_links_rejects_link_type(context: Any, link_type_value: str) -> None:
with context.engine.begin() as conn:
parent_id = "01LINKTYPE_INVALIDP_TEST"
child_id = "01LINKTYPE_INVALIDC_TEST"
for rid in (parent_id, child_id):
_insert_test_resource(conn, rid)
try:
conn.execute(
text(
"""
INSERT INTO resource_links (parent_id, child_id, link_type, created_at)
VALUES (:parent_id, :child_id, :link_type, :created_at)
"""
),
{
"parent_id": parent_id,
"child_id": child_id,
"link_type": link_type_value,
"created_at": "2026-01-01T00:00:00",
},
)
except IntegrityError:
return
raise AssertionError(
f"resource_links accepted invalid link_type {link_type_value!r}"
)
@then("resource_links should reject empty string link_type")
def step_resource_links_rejects_empty_link_type(context: Any) -> None:
with context.engine.begin() as conn:
parent_id = "01LINKTYPE_EMPTYP_TESTXX"
child_id = "01LINKTYPE_EMPTYC_TESTXX"
for rid in (parent_id, child_id):
_insert_test_resource(conn, rid)
try:
conn.execute(
text(
"""
INSERT INTO resource_links (parent_id, child_id, link_type, created_at)
VALUES (:parent_id, :child_id, :link_type, :created_at)
"""
),
{
"parent_id": parent_id,
"child_id": child_id,
"link_type": "",
"created_at": "2026-01-01T00:00:00",
},
)
except IntegrityError:
return
raise AssertionError("resource_links accepted empty string link_type")
# ---------------------------------------------------------------------------
# When/Then — downgrade verification
# ---------------------------------------------------------------------------
@when('I downgrade to revision "{revision}"')
def step_downgrade_to_specific_revision(context: Any, revision: str) -> None:
if not hasattr(context, "runner"):
runner = MigrationRunner(context.db_url)
runner.run_migrations(engine=context.engine)
context.runner = runner
runner = context.runner
with context.engine.connect() as conn:
runner.alembic_cfg.attributes["connection"] = conn
try:
command.downgrade(runner.alembic_cfg, revision)
conn.commit()
finally:
runner.alembic_cfg.attributes.pop("connection", None)
@then('the "resource_links" table should not include "{column_name}"')
def step_table_should_not_include_column(context: Any, column_name: str) -> None:
inspector = sa_inspect(context.engine)
columns = {col["name"] for col in inspector.get_columns("resource_links")}
assert column_name not in columns, (
f"Expected column {column_name!r} to be absent from resource_links, "
f"but found it in {columns!r}"
)
@then('the "decisions" table should not have index "{index_name}"')
def step_decisions_should_not_have_index(context: Any, index_name: str) -> None:
inspector = sa_inspect(context.engine)
indexes = {idx["name"] for idx in inspector.get_indexes("decisions")}
assert index_name not in indexes, (
f"Expected index {index_name!r} to be absent from decisions, "
f"but found it in {indexes!r}"
)
@then('checkpoint_metadata should not have foreign key "{fk_name}"')
def step_checkpoint_should_not_have_fk(context: Any, fk_name: str) -> None:
inspector = sa_inspect(context.engine)
fks = inspector.get_foreign_keys("checkpoint_metadata")
fk_names = {fk.get("name") for fk in fks}
assert fk_name not in fk_names, (
f"Expected FK {fk_name!r} to be absent from checkpoint_metadata, "
f"but found it in {fk_names!r}"
)
@then("checkpoint_metadata should not have SQLite triggers for FK enforcement")
def step_checkpoint_no_fk_triggers(context: Any) -> None:
if context.engine.dialect.name != "sqlite":
return # Triggers are SQLite-specific; skip on other dialects.
with context.engine.connect() as conn:
rows = conn.execute(
text(
"SELECT name FROM sqlite_master "
"WHERE type = 'trigger' AND name LIKE 'trg_checkpoint_metadata_%'"
)
).fetchall()
trigger_names = [row[0] for row in rows]
assert not trigger_names, (
f"Expected no trg_checkpoint_metadata_* triggers after downgrade, "
f"found: {trigger_names!r}"
)
+427
View File
@@ -0,0 +1,427 @@
"""Step definitions for db_migration_lifecycle.feature — schema parity.
FK structure verification, orphan rejection, valid references, and
partial index checks for the ``resource_links``, ``checkpoint_metadata``,
and ``decisions`` tables.
"""
from __future__ import annotations
from typing import Any
from behave import then
from sqlalchemy import inspect as sa_inspect
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
# ---------------------------------------------------------------------------
# Shared test-data helpers
# ---------------------------------------------------------------------------
def _ensure_test_action_and_plan(conn: Any, action_name: str, plan_id: str) -> None:
"""Insert prerequisite action and plan rows if absent."""
existing_action = conn.execute(
text("SELECT 1 FROM actions WHERE namespaced_name = :n"),
{"n": action_name},
).fetchone()
existing_plan = conn.execute(
text("SELECT 1 FROM v3_plans WHERE plan_id = :pid"),
{"pid": plan_id},
).fetchone()
if existing_action is not None and existing_plan is not None:
return
if existing_action is None:
conn.execute(
text(
"""
INSERT INTO actions (
namespaced_name, namespace, name, description,
definition_of_done, strategy_actor, execution_actor,
created_at, updated_at
) VALUES (
:namespaced_name, :namespace, :name, :description,
:definition_of_done, :strategy_actor, :execution_actor,
:created_at, :updated_at
)
"""
),
{
"namespaced_name": action_name,
"namespace": "local",
"name": action_name.split("/")[-1],
"description": "test action",
"definition_of_done": "test dod",
"strategy_actor": "local/strategy",
"execution_actor": "local/execution",
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:00",
},
)
if existing_plan is None:
conn.execute(
text(
"""
INSERT INTO v3_plans (
plan_id, root_plan_id, action_name,
namespaced_name, namespace,
description, created_at, updated_at
) VALUES (
:plan_id, :root_plan_id, :action_name,
:namespaced_name, :namespace,
:description, :created_at, :updated_at
)
"""
),
{
"plan_id": plan_id,
"root_plan_id": plan_id,
"action_name": action_name,
"namespaced_name": "local/test-plan-fk",
"namespace": "local",
"description": "test plan",
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:00",
},
)
def _insert_test_resource(
conn: Any,
resource_id: str,
type_name: str = "git-checkout",
resource_kind: str = "physical",
) -> None:
"""Insert a test resource row, handling the optional namespaced_name column."""
resource_columns = {
col["name"] for col in sa_inspect(conn).get_columns("resources")
}
cols = "resource_id, type_name, resource_kind, created_at, updated_at"
vals = ":resource_id, :type_name, :resource_kind, :created_at, :updated_at"
params: dict[str, str] = {
"resource_id": resource_id,
"type_name": type_name,
"resource_kind": resource_kind,
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:00",
}
if "namespaced_name" in resource_columns:
cols = (
"resource_id, namespaced_name, type_name,"
" resource_kind, created_at, updated_at"
)
vals = (
":resource_id, :namespaced_name, :type_name,"
" :resource_kind, :created_at, :updated_at"
)
params["namespaced_name"] = f"local/{resource_id}"
conn.execute(text(f"INSERT INTO resources ({cols}) VALUES ({vals})"), params)
# ---------------------------------------------------------------------------
# Then — link_type default verification
# ---------------------------------------------------------------------------
@then('the "resource_links" table should include "link_type" with default "contains"')
def step_resource_links_has_link_type_default(context: Any) -> None:
inspector = sa_inspect(context.engine)
columns = inspector.get_columns("resource_links")
link_type = next(
(column for column in columns if column["name"] == "link_type"), None
)
assert link_type is not None, "resource_links.link_type column is missing"
default = str(link_type.get("default") or "").lower()
assert "contains" in default, (
"Expected resource_links.link_type default to include 'contains', "
f"got: {link_type.get('default')!r}"
)
# ---------------------------------------------------------------------------
# Then — checkpoint FK structure verification
# ---------------------------------------------------------------------------
@then("checkpoint_metadata should enforce decision and resource foreign keys")
def step_checkpoint_metadata_foreign_keys(context: Any) -> None:
inspector = sa_inspect(context.engine)
foreign_keys = inspector.get_foreign_keys("checkpoint_metadata")
signatures = {
(
tuple(fk.get("constrained_columns") or []),
fk.get("referred_table"),
tuple(fk.get("referred_columns") or []),
)
for fk in foreign_keys
}
decision_fk = (("decision_id",), "decisions", ("decision_id",))
resource_fk = (("resource_id",), "resources", ("resource_id",))
assert decision_fk in signatures, (
"Missing checkpoint_metadata foreign key for decision_id -> "
"decisions.decision_id"
)
assert resource_fk in signatures, (
"Missing checkpoint_metadata foreign key for resource_id -> "
"resources.resource_id"
)
# ---------------------------------------------------------------------------
# Then — orphan FK rejection (combined)
# ---------------------------------------------------------------------------
@then("checkpoint_metadata foreign keys should reject orphan references")
def step_checkpoint_metadata_foreign_keys_reject_orphans(context: Any) -> None:
action_name = "local/test-action"
plan_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
with context.engine.begin() as conn:
_ensure_test_action_and_plan(conn, action_name, plan_id)
try:
conn.execute(
text(
"""
INSERT INTO checkpoint_metadata (
checkpoint_id,
plan_id,
decision_id,
checkpoint_type,
resource_id,
sandbox_ref,
filesystem_path,
created_at
) VALUES (
:checkpoint_id,
:plan_id,
:decision_id,
:checkpoint_type,
:resource_id,
:sandbox_ref,
:filesystem_path,
:created_at
)
"""
),
{
"checkpoint_id": "01ARZ3NDEKTSV4RRFFQ69G5FAW",
"plan_id": plan_id,
"decision_id": "01ARZ3NDEKTSV4RRFFQ69G5FAX",
"checkpoint_type": "manual",
"resource_id": "01ARZ3NDEKTSV4RRFFQ69G5FAY",
"sandbox_ref": "test-ref",
"filesystem_path": "",
"created_at": "2026-01-01T00:00:00",
},
)
except IntegrityError:
return
raise AssertionError(
"checkpoint_metadata accepted orphan decision/resource references"
)
# ---------------------------------------------------------------------------
# Then — independent orphan FK rejection
# ---------------------------------------------------------------------------
@then("checkpoint_metadata should reject orphan decision_id independently")
def step_checkpoint_reject_orphan_decision_only(context: Any) -> None:
action_name = "local/test-action-fk-d"
plan_id = "01ARZ3NDEKTSV4RRFFQ69G5FB0"
with context.engine.begin() as conn:
_ensure_test_action_and_plan(conn, action_name, plan_id)
try:
conn.execute(
text(
"""
INSERT INTO checkpoint_metadata (
checkpoint_id, plan_id, decision_id,
checkpoint_type, sandbox_ref,
filesystem_path, created_at
) VALUES (
:checkpoint_id, :plan_id, :decision_id,
:checkpoint_type, :sandbox_ref,
:filesystem_path, :created_at
)
"""
),
{
"checkpoint_id": "01ARZ3NDEKTSV4RRFFQ69G5FB1",
"plan_id": plan_id,
"decision_id": "01ARZ3NDEKTSV4RRFFQ69G5FB2",
"checkpoint_type": "manual",
"sandbox_ref": "test-ref",
"filesystem_path": "",
"created_at": "2026-01-01T00:00:00",
},
)
except IntegrityError:
return
raise AssertionError(
"checkpoint_metadata accepted orphan decision_id with NULL resource_id"
)
@then("checkpoint_metadata should reject orphan resource_id independently")
def step_checkpoint_reject_orphan_resource_only(context: Any) -> None:
action_name = "local/test-action-fk-r"
plan_id = "01ARZ3NDEKTSV4RRFFQ69G5FB3"
with context.engine.begin() as conn:
_ensure_test_action_and_plan(conn, action_name, plan_id)
try:
conn.execute(
text(
"""
INSERT INTO checkpoint_metadata (
checkpoint_id, plan_id, resource_id,
checkpoint_type, sandbox_ref,
filesystem_path, created_at
) VALUES (
:checkpoint_id, :plan_id, :resource_id,
:checkpoint_type, :sandbox_ref,
:filesystem_path, :created_at
)
"""
),
{
"checkpoint_id": "01ARZ3NDEKTSV4RRFFQ69G5FB4",
"plan_id": plan_id,
"resource_id": "01ARZ3NDEKTSV4RRFFQ69G5FB5",
"checkpoint_type": "manual",
"sandbox_ref": "test-ref",
"filesystem_path": "",
"created_at": "2026-01-01T00:00:00",
},
)
except IntegrityError:
return
raise AssertionError(
"checkpoint_metadata accepted orphan resource_id with NULL decision_id"
)
# ---------------------------------------------------------------------------
# Then — valid FK acceptance
# ---------------------------------------------------------------------------
@then("checkpoint_metadata should accept valid decision and resource references")
def step_checkpoint_accept_valid_references(context: Any) -> None:
action_name = "local/test-action-fk-v"
plan_id = "01ARZ3NDEKTSV4RRFFQ69G5FB6"
decision_id = "01ARZ3NDEKTSV4RRFFQ69G5FB7"
resource_id = "01ARZ3NDEKTSV4RRFFQ69G5FB8"
with context.engine.begin() as conn:
_ensure_test_action_and_plan(conn, action_name, plan_id)
conn.execute(
text(
"""
INSERT INTO decisions (
decision_id, plan_id, decision_type, question,
chosen_option, context_snapshot_json, sequence_number,
created_at
) VALUES (
:decision_id, :plan_id, :decision_type, :question,
:chosen_option, :context_snapshot_json, :sequence_number,
:created_at
)
"""
),
{
"decision_id": decision_id,
"plan_id": plan_id,
"decision_type": "strategy_choice",
"question": "test question",
"chosen_option": "test option",
"context_snapshot_json": "{}",
"sequence_number": 1,
"created_at": "2026-01-01T00:00:00",
},
)
_insert_test_resource(conn, resource_id)
conn.execute(
text(
"""
INSERT INTO checkpoint_metadata (
checkpoint_id, plan_id, decision_id,
checkpoint_type, resource_id, sandbox_ref,
filesystem_path, created_at
) VALUES (
:checkpoint_id, :plan_id, :decision_id,
:checkpoint_type, :resource_id, :sandbox_ref,
:filesystem_path, :created_at
)
"""
),
{
"checkpoint_id": "01ARZ3NDEKTSV4RRFFQ69G5FB9",
"plan_id": plan_id,
"decision_id": decision_id,
"checkpoint_type": "manual",
"resource_id": resource_id,
"sandbox_ref": "test-ref",
"filesystem_path": "",
"created_at": "2026-01-01T00:00:00",
},
)
# ---------------------------------------------------------------------------
# Then — partial index verification
# ---------------------------------------------------------------------------
@then(
'the "decisions" table should have partial index "{index_name}" on "{column_name}"'
)
def step_decisions_has_partial_index(
context: Any,
index_name: str,
column_name: str,
) -> None:
inspector = sa_inspect(context.engine)
indexes = inspector.get_indexes("decisions")
index = next((idx for idx in indexes if idx.get("name") == index_name), None)
assert index is not None, f"Index {index_name} not found on decisions"
columns = list(index.get("column_names") or [])
assert columns == [column_name], (
f"Index {index_name} expected on [{column_name!r}], got {columns!r}"
)
# Verify partial WHERE clause via sqlite_master (SQLite-only).
if context.engine.dialect.name == "sqlite":
with context.engine.connect() as conn:
row = conn.execute(
text(
"SELECT sql FROM sqlite_master "
"WHERE type = 'index' AND name = :index_name"
),
{"index_name": index_name},
).fetchone()
assert row is not None, f"sqlite_master entry missing for index {index_name}"
sql = str(row[0] or "").lower()
assert "where superseded_by is not null" in sql, (
f"Expected partial WHERE clause on {index_name}, got SQL: {row[0]!r}"
)
+443
View File
@@ -0,0 +1,443 @@
"""Robot helper for schema-parity migration verification.
Checks the migration-produced SQLite schema for:
1. ``resource_links.link_type`` defaulting to ``contains``.
2. ``checkpoint_metadata`` foreign keys to ``decisions`` and ``resources``.
3. ``idx_decisions_superseded`` partial index with
``WHERE superseded_by IS NOT NULL``.
4. Runtime SQLite foreign key enforcement for ``checkpoint_metadata``.
Subcommands (each independent for isolated failure reporting):
- ``schema-parity-link-type``: Verifies resource_links.link_type column
and default.
- ``schema-parity-fks``: Verifies checkpoint_metadata FK constraints and
runtime enforcement (orphan rejection + positive path).
- ``schema-parity-index``: Verifies idx_decisions_superseded partial index.
"""
from __future__ import annotations
import sys
import tempfile
from collections.abc import Callable
from pathlib import Path
from typing import Any
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.exc import IntegrityError
from cleveragents.infrastructure.database.migration_runner import MigrationRunner
def _cleanup_db_files(path: str) -> None:
Path(path).unlink(missing_ok=True)
Path(f"{path}-wal").unlink(missing_ok=True)
Path(f"{path}-shm").unlink(missing_ok=True)
def _setup_migrated_db() -> tuple[Any, Any, str]:
"""Create a temp DB, run migrations, return (engine, inspector, db_path)."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp_file:
db_path = tmp_file.name
db_url = f"sqlite:///{db_path}"
MigrationRunner(db_url).init_or_upgrade()
engine = create_engine(db_url, connect_args={"check_same_thread": False})
inspector = inspect(engine)
return engine, inspector, db_path
def _teardown_db(engine: Any, db_path: str) -> None:
if engine is not None:
engine.dispose()
_cleanup_db_files(db_path)
def _ensure_test_action_and_plan(conn: Any) -> None:
"""Insert prerequisite action and plan rows for FK tests.
.. note::
This function is **not idempotent** it performs blind INSERTs
without existence checks. It is safe only when called against a
freshly created database (as ``_setup_migrated_db`` provides).
If idempotent behaviour is needed, see the Behave counterpart in
``features/steps/db_schema_parity_steps.py``.
"""
conn.execute(
text(
"""
INSERT INTO actions (
namespaced_name, namespace, name, description,
definition_of_done, strategy_actor, execution_actor,
created_at, updated_at
) VALUES (
:namespaced_name, :namespace, :name, :description,
:definition_of_done, :strategy_actor, :execution_actor,
:created_at, :updated_at
)
"""
),
{
"namespaced_name": "local/test-action",
"namespace": "local",
"name": "test-action",
"description": "test action",
"definition_of_done": "test dod",
"strategy_actor": "local/strategy",
"execution_actor": "local/execution",
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:00",
},
)
conn.execute(
text(
"""
INSERT INTO v3_plans (
plan_id, root_plan_id, action_name,
namespaced_name, namespace,
description, created_at, updated_at
) VALUES (
:plan_id, :root_plan_id, :action_name,
:namespaced_name, :namespace,
:description, :created_at, :updated_at
)
"""
),
{
"plan_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"root_plan_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"action_name": "local/test-action",
"namespaced_name": "local/test-plan",
"namespace": "local",
"description": "test plan",
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:00",
},
)
# ---------------------------------------------------------------------------
# Subcommand: schema-parity-link-type
# ---------------------------------------------------------------------------
def _schema_parity_link_type() -> None:
engine, inspector, db_path = _setup_migrated_db()
try:
resource_link_columns = inspector.get_columns("resource_links")
link_type = next(
(
column
for column in resource_link_columns
if column["name"] == "link_type"
),
None,
)
assert link_type is not None, "resource_links.link_type column is missing"
default = str(link_type.get("default") or "").lower()
assert "contains" in default, (
"resource_links.link_type default must include 'contains', "
f"got {link_type.get('default')!r}"
)
print("schema-parity-link-type-ok")
finally:
_teardown_db(engine, db_path)
# ---------------------------------------------------------------------------
# Subcommand: schema-parity-fks
# ---------------------------------------------------------------------------
def _schema_parity_fks() -> None:
engine, inspector, db_path = _setup_migrated_db()
try:
checkpoint_fks = inspector.get_foreign_keys("checkpoint_metadata")
signatures = {
(
tuple(fk.get("constrained_columns") or []),
fk.get("referred_table"),
tuple(fk.get("referred_columns") or []),
)
for fk in checkpoint_fks
}
assert (("decision_id",), "decisions", ("decision_id",)) in signatures, (
"Missing checkpoint_metadata FK decision_id -> decisions.decision_id"
)
assert (("resource_id",), "resources", ("resource_id",)) in signatures, (
"Missing checkpoint_metadata FK resource_id -> resources.resource_id"
)
with engine.begin() as conn:
_ensure_test_action_and_plan(conn)
try:
conn.execute(
text(
"""
INSERT INTO checkpoint_metadata (
checkpoint_id, plan_id, decision_id,
checkpoint_type, resource_id, sandbox_ref,
filesystem_path, created_at
) VALUES (
:checkpoint_id, :plan_id, :decision_id,
:checkpoint_type, :resource_id, :sandbox_ref,
:filesystem_path, :created_at
)
"""
),
{
"checkpoint_id": "01ARZ3NDEKTSV4RRFFQ69G5FAW",
"plan_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"decision_id": "01ARZ3NDEKTSV4RRFFQ69G5FAX",
"checkpoint_type": "manual",
"resource_id": "01ARZ3NDEKTSV4RRFFQ69G5FAY",
"sandbox_ref": "test-ref",
"filesystem_path": "",
"created_at": "2026-01-01T00:00:00",
},
)
except IntegrityError:
pass
else:
raise AssertionError(
"checkpoint_metadata accepted orphan decision/resource references"
)
# Verify each FK independently: orphan decision_id only
with engine.begin() as conn:
try:
conn.execute(
text(
"""
INSERT INTO checkpoint_metadata (
checkpoint_id, plan_id, decision_id,
checkpoint_type, sandbox_ref,
filesystem_path, created_at
) VALUES (
:checkpoint_id, :plan_id, :decision_id,
:checkpoint_type, :sandbox_ref,
:filesystem_path, :created_at
)
"""
),
{
"checkpoint_id": "01ARZ3NDEKTSV4RRFFQ69G5FC0",
"plan_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"decision_id": "01ARZ3NDEKTSV4RRFFQ69G5FC1",
"checkpoint_type": "manual",
"sandbox_ref": "test-ref",
"filesystem_path": "",
"created_at": "2026-01-01T00:00:00",
},
)
except IntegrityError:
pass
else:
raise AssertionError(
"checkpoint_metadata accepted orphan decision_id independently"
)
# Verify each FK independently: orphan resource_id only
with engine.begin() as conn:
try:
conn.execute(
text(
"""
INSERT INTO checkpoint_metadata (
checkpoint_id, plan_id, resource_id,
checkpoint_type, sandbox_ref,
filesystem_path, created_at
) VALUES (
:checkpoint_id, :plan_id, :resource_id,
:checkpoint_type, :sandbox_ref,
:filesystem_path, :created_at
)
"""
),
{
"checkpoint_id": "01ARZ3NDEKTSV4RRFFQ69G5FC2",
"plan_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"resource_id": "01ARZ3NDEKTSV4RRFFQ69G5FC3",
"checkpoint_type": "manual",
"sandbox_ref": "test-ref",
"filesystem_path": "",
"created_at": "2026-01-01T00:00:00",
},
)
except IntegrityError:
pass
else:
raise AssertionError(
"checkpoint_metadata accepted orphan resource_id independently"
)
# Positive test: valid FK references should be accepted
with engine.begin() as conn:
decision_id = "01ARZ3NDEKTSV4RRFFQ69G5FC4"
resource_id = "01ARZ3NDEKTSV4RRFFQ69G5FC5"
conn.execute(
text(
"""
INSERT INTO decisions (
decision_id, plan_id, decision_type, question,
chosen_option, context_snapshot_json, sequence_number,
created_at
) VALUES (
:decision_id, :plan_id, :decision_type, :question,
:chosen_option, :context_snapshot_json, :sequence_number,
:created_at
)
"""
),
{
"decision_id": decision_id,
"plan_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"decision_type": "strategy_choice",
"question": "test question",
"chosen_option": "test option",
"context_snapshot_json": "{}",
"sequence_number": 1,
"created_at": "2026-01-01T00:00:00",
},
)
resource_columns = {
col["name"] for col in inspect(engine).get_columns("resources")
}
cols = "resource_id, type_name, resource_kind, created_at, updated_at"
vals = ":resource_id, :type_name, :resource_kind, :created_at, :updated_at"
res_params: dict[str, str] = {
"resource_id": resource_id,
"type_name": "git-checkout",
"resource_kind": "physical",
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:00",
}
if "namespaced_name" in resource_columns:
cols = (
"resource_id, namespaced_name, type_name,"
" resource_kind, created_at, updated_at"
)
vals = (
":resource_id, :namespaced_name, :type_name,"
" :resource_kind, :created_at, :updated_at"
)
res_params["namespaced_name"] = f"local/{resource_id}"
conn.execute(
text(f"INSERT INTO resources ({cols}) VALUES ({vals})"),
res_params,
)
conn.execute(
text(
"""
INSERT INTO checkpoint_metadata (
checkpoint_id, plan_id, decision_id,
checkpoint_type, resource_id, sandbox_ref,
filesystem_path, created_at
) VALUES (
:checkpoint_id, :plan_id, :decision_id,
:checkpoint_type, :resource_id, :sandbox_ref,
:filesystem_path, :created_at
)
"""
),
{
"checkpoint_id": "01ARZ3NDEKTSV4RRFFQ69G5FC6",
"plan_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"decision_id": decision_id,
"checkpoint_type": "manual",
"resource_id": resource_id,
"sandbox_ref": "test-ref",
"filesystem_path": "",
"created_at": "2026-01-01T00:00:00",
},
)
print("schema-parity-fks-ok")
finally:
_teardown_db(engine, db_path)
# ---------------------------------------------------------------------------
# Subcommand: schema-parity-index
# ---------------------------------------------------------------------------
def _schema_parity_index() -> None:
engine, inspector, db_path = _setup_migrated_db()
try:
decision_indexes = inspector.get_indexes("decisions")
partial_index = next(
(
index
for index in decision_indexes
if index.get("name") == "idx_decisions_superseded"
),
None,
)
assert partial_index is not None, "idx_decisions_superseded is missing"
assert list(partial_index.get("column_names") or []) == ["superseded_by"], (
"idx_decisions_superseded must index decisions.superseded_by"
)
with engine.connect() as conn:
row = conn.execute(
text(
"SELECT sql FROM sqlite_master "
"WHERE type = 'index' AND name = :index_name"
),
{"index_name": "idx_decisions_superseded"},
).fetchone()
assert row is not None, "sqlite_master is missing idx_decisions_superseded"
sql = str(row[0] or "").lower()
assert "where superseded_by is not null" in sql, (
"idx_decisions_superseded is not a partial index"
)
print("schema-parity-index-ok")
finally:
_teardown_db(engine, db_path)
# ---------------------------------------------------------------------------
# Legacy combined subcommand (kept for backward compatibility)
# ---------------------------------------------------------------------------
def _schema_parity() -> None:
_schema_parity_link_type()
_schema_parity_fks()
_schema_parity_index()
print("schema-parity-ok")
_COMMANDS: dict[str, Callable[[], None]] = {
"schema-parity": _schema_parity,
"schema-parity-link-type": _schema_parity_link_type,
"schema-parity-fks": _schema_parity_fks,
"schema-parity-index": _schema_parity_index,
}
def main() -> None:
if len(sys.argv) < 2:
raise SystemExit(f"Expected command argument. Valid: {', '.join(_COMMANDS)}")
command = sys.argv[1]
handler = _COMMANDS.get(command)
if handler is None:
raise SystemExit(f"Unknown command: {command}. Valid: {', '.join(_COMMANDS)}")
handler()
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
*** Settings ***
Documentation Integration checks for spec-parity schema constraints and indexes.
... Verifies migration output includes resource_links.link_type default,
... checkpoint_metadata FK enforcement, and decisions partial superseded index.
... Split into independent test cases for isolated failure reporting.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER_SCRIPT} robot/helper_schema_parity_migration.py
*** Test Cases ***
Resource Links Link Type Default After Migration
[Documentation] Validate resource_links.link_type column exists with default 'contains'.
[Tags] database migration integration link-type
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} schema-parity-link-type cwd=${WORKSPACE}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} schema-parity-link-type-ok
Checkpoint Metadata FK Enforcement After Migration
[Documentation] Validate checkpoint_metadata FK constraints and orphan rejection.
[Tags] database migration integration fks
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} schema-parity-fks cwd=${WORKSPACE}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} schema-parity-fks-ok
Decisions Superseded Partial Index After Migration
[Documentation] Validate idx_decisions_superseded partial index on decisions.superseded_by.
[Tags] database migration integration index
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} schema-parity-index cwd=${WORKSPACE}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} schema-parity-index-ok
@@ -0,0 +1,318 @@
"""Align resource/decision/checkpoint schema gaps with specification DDL.
Adds four spec-parity changes:
1. ``resource_links.link_type`` with default ``'contains'``.
2. Foreign keys on ``checkpoint_metadata.decision_id`` and
``checkpoint_metadata.resource_id``.
3. Partial index ``idx_decisions_superseded`` on
``decisions.superseded_by`` where non-null.
4. SQLite trigger guards to enforce checkpoint FK semantics at runtime.
Revision ID: m4_004_schema_parity_resource_decision_checkpoint
Revises: m4_003_plan_env_columns
Create Date: 2026-03-26 00:00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "m4_004_schema_parity_resource_decision_checkpoint"
down_revision: str | Sequence[str] | None = "m4_003_plan_env_columns"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _inspector() -> sa.Inspector:
return sa.inspect(op.get_bind())
def _create_sqlite_checkpoint_fk_triggers() -> None:
# NOTE: These triggers guard INSERT and UPDATE on checkpoint_metadata only.
# DELETE-direction enforcement (preventing deletion of a referenced decision
# or resource) relies on PRAGMA foreign_keys=ON at the connection level.
# This is a known limitation of trigger-based FK emulation on SQLite.
op.execute(
sa.text(
"""
CREATE TRIGGER IF NOT EXISTS trg_checkpoint_metadata_decision_fk_insert
BEFORE INSERT ON checkpoint_metadata
FOR EACH ROW
WHEN NEW.decision_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM decisions WHERE decision_id = NEW.decision_id
)
BEGIN
SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed');
END;
"""
)
)
op.execute(
sa.text(
"""
CREATE TRIGGER IF NOT EXISTS trg_checkpoint_metadata_decision_fk_update
BEFORE UPDATE OF decision_id ON checkpoint_metadata
FOR EACH ROW
WHEN NEW.decision_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM decisions WHERE decision_id = NEW.decision_id
)
BEGIN
SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed');
END;
"""
)
)
op.execute(
sa.text(
"""
CREATE TRIGGER IF NOT EXISTS trg_checkpoint_metadata_resource_fk_insert
BEFORE INSERT ON checkpoint_metadata
FOR EACH ROW
WHEN NEW.resource_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM resources WHERE resource_id = NEW.resource_id
)
BEGIN
SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed');
END;
"""
)
)
op.execute(
sa.text(
"""
CREATE TRIGGER IF NOT EXISTS trg_checkpoint_metadata_resource_fk_update
BEFORE UPDATE OF resource_id ON checkpoint_metadata
FOR EACH ROW
WHEN NEW.resource_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM resources WHERE resource_id = NEW.resource_id
)
BEGIN
SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed');
END;
"""
)
)
def _drop_sqlite_checkpoint_fk_triggers() -> None:
op.execute(
sa.text("DROP TRIGGER IF EXISTS trg_checkpoint_metadata_decision_fk_insert")
)
op.execute(
sa.text("DROP TRIGGER IF EXISTS trg_checkpoint_metadata_decision_fk_update")
)
op.execute(
sa.text("DROP TRIGGER IF EXISTS trg_checkpoint_metadata_resource_fk_insert")
)
op.execute(
sa.text("DROP TRIGGER IF EXISTS trg_checkpoint_metadata_resource_fk_update")
)
def upgrade() -> None:
"""Apply spec-parity schema updates."""
inspector = _inspector()
resource_link_columns = {
column["name"] for column in inspector.get_columns("resource_links")
}
if "link_type" not in resource_link_columns:
with op.batch_alter_table("resource_links") as batch_op:
batch_op.add_column(
sa.Column(
"link_type",
sa.Text(),
nullable=False,
server_default=sa.text("'contains'"),
)
)
# NOTE: Spec DDL (line 45549) does not declare a CHECK on
# link_type; we add it to match the sibling resource_edges
# constraint and prevent invalid values at the DB level.
batch_op.create_check_constraint(
"ck_resource_links_link_type",
"link_type IN ('contains', 'references', 'derived_from')",
)
else:
# link_type column already exists — fix default and ensure CHECK
# constraint is present (guards against partial prior migration).
# NOTE: Spec DDL (line 45549) defines link_type as nullable
# (TEXT DEFAULT 'contains' without NOT NULL). We deliberately
# enforce NOT NULL because a NULL link type is semantically
# meaningless and the sibling resource_edges table also requires
# a non-NULL link_type.
# NOTE: Column TYPE is not verified here. On SQLite all text
# types are equivalent, so a prior VARCHAR(30) column works
# identically to TEXT. On PostgreSQL, VARCHAR(30) != TEXT; if
# a future migration targets PostgreSQL this path should also
# include an ALTER COLUMN TYPE to sa.Text().
link_type_column = next(
column
for column in inspector.get_columns("resource_links")
if column["name"] == "link_type"
)
current_default = str(link_type_column.get("default") or "").lower()
needs_default_fix = "contains" not in current_default
check_constraints = inspector.get_check_constraints("resource_links")
has_link_type_ck = any(
ck.get("name") == "ck_resource_links_link_type" for ck in check_constraints
)
if needs_default_fix or not has_link_type_ck:
with op.batch_alter_table("resource_links") as batch_op:
if needs_default_fix:
batch_op.alter_column(
"link_type",
server_default=sa.text("'contains'"),
)
if not has_link_type_ck:
batch_op.create_check_constraint(
"ck_resource_links_link_type",
"link_type IN ('contains', 'references', 'derived_from')",
)
decision_indexes = {index["name"] for index in inspector.get_indexes("decisions")}
if "idx_decisions_superseded" not in decision_indexes:
op.create_index(
"idx_decisions_superseded",
"decisions",
["superseded_by"],
unique=False,
postgresql_where=sa.text("superseded_by IS NOT NULL"),
sqlite_where=sa.text("superseded_by IS NOT NULL"),
)
# Re-inspect after potential DDL changes above (link_type column, index).
inspector = _inspector()
checkpoint_fks = inspector.get_foreign_keys("checkpoint_metadata")
fk_signatures = {
(
tuple(fk.get("constrained_columns") or []),
fk.get("referred_table"),
tuple(fk.get("referred_columns") or []),
)
for fk in checkpoint_fks
}
needs_decision_fk = (
("decision_id",),
"decisions",
("decision_id",),
) not in fk_signatures
needs_resource_fk = (
("resource_id",),
"resources",
("resource_id",),
) not in fk_signatures
if needs_decision_fk or needs_resource_fk:
# Nullify orphan references before creating FK constraints so the
# migration does not fail on existing data with dangling IDs.
# Uses NOT EXISTS instead of NOT IN for better query-plan
# performance on large checkpoint_metadata tables.
op.execute(
sa.text(
"""
UPDATE checkpoint_metadata
SET decision_id = NULL
WHERE decision_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM decisions d
WHERE d.decision_id = checkpoint_metadata.decision_id
)
"""
)
)
op.execute(
sa.text(
"""
UPDATE checkpoint_metadata
SET resource_id = NULL
WHERE resource_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM resources r
WHERE r.resource_id = checkpoint_metadata.resource_id
)
"""
)
)
# NOTE: Spec DDL (lines 45569, 45571) uses bare REFERENCES without
# an ON DELETE clause (default: NO ACTION / RESTRICT). We use
# ondelete="SET NULL" so that DecisionRepository.delete() and
# ResourceRepository.delete() do not raise IntegrityError when a
# parent decision or resource is removed while checkpoint rows
# still reference it.
with op.batch_alter_table("checkpoint_metadata") as batch_op:
if needs_decision_fk:
batch_op.create_foreign_key(
"fk_checkpoint_metadata_decision",
"decisions",
["decision_id"],
["decision_id"],
ondelete="SET NULL",
)
if needs_resource_fk:
batch_op.create_foreign_key(
"fk_checkpoint_metadata_resource",
"resources",
["resource_id"],
["resource_id"],
ondelete="SET NULL",
)
if op.get_bind().dialect.name == "sqlite":
_create_sqlite_checkpoint_fk_triggers()
def downgrade() -> None:
"""Revert spec-parity schema updates."""
inspector = _inspector()
if op.get_bind().dialect.name == "sqlite":
_drop_sqlite_checkpoint_fk_triggers()
decision_indexes = {index["name"] for index in inspector.get_indexes("decisions")}
if "idx_decisions_superseded" in decision_indexes:
op.drop_index("idx_decisions_superseded", table_name="decisions")
checkpoint_fks = inspector.get_foreign_keys("checkpoint_metadata")
fk_names = {fk.get("name") for fk in checkpoint_fks}
if (
"fk_checkpoint_metadata_decision" in fk_names
or "fk_checkpoint_metadata_resource" in fk_names
):
with op.batch_alter_table("checkpoint_metadata") as batch_op:
if "fk_checkpoint_metadata_decision" in fk_names:
batch_op.drop_constraint(
"fk_checkpoint_metadata_decision",
type_="foreignkey",
)
if "fk_checkpoint_metadata_resource" in fk_names:
batch_op.drop_constraint(
"fk_checkpoint_metadata_resource",
type_="foreignkey",
)
resource_link_columns = {
column["name"] for column in inspector.get_columns("resource_links")
}
if "link_type" in resource_link_columns:
check_constraints = inspector.get_check_constraints("resource_links")
has_link_type_ck = any(
ck.get("name") == "ck_resource_links_link_type" for ck in check_constraints
)
with op.batch_alter_table("resource_links") as batch_op:
if has_link_type_ck:
batch_op.drop_constraint("ck_resource_links_link_type", type_="check")
batch_op.drop_column("link_type")
@@ -1,13 +1,14 @@
"""Merge profile-rename, correction-attempts, and plans-schema heads. """Merge profile-rename, correction-attempts, plans-schema, and schema-parity heads.
m5_001_rename_profile_fields, m8_001_correction_attempts, and m5_001_rename_profile_fields, m8_001_correction_attempts,
m8_001_align_plans_schema all branched from m4_003_plan_env_columns, m8_001_align_plans_schema, and m4_004_schema_parity_resource_decision_checkpoint
creating three Alembic heads. This no-op merge migration resolves all branched from m4_003_plan_env_columns, creating four Alembic heads.
them into a single head. This no-op merge migration resolves them into a single head.
Revision ID: m8_002_merge_profile_rename_and_corrections Revision ID: m8_002_merge_profile_rename_and_corrections
Revises: m5_001_rename_profile_fields, m8_001_correction_attempts, Revises: m5_001_rename_profile_fields, m8_001_correction_attempts,
m8_001_align_plans_schema m8_001_align_plans_schema,
m4_004_schema_parity_resource_decision_checkpoint
Create Date: 2026-03-29 00:00:00 Create Date: 2026-03-29 00:00:00
""" """
@@ -20,6 +21,7 @@ down_revision: str | Sequence[str] | None = (
"m5_001_rename_profile_fields", "m5_001_rename_profile_fields",
"m8_001_correction_attempts", "m8_001_correction_attempts",
"m8_001_align_plans_schema", "m8_001_align_plans_schema",
"m4_004_schema_parity_resource_decision_checkpoint",
) )
branch_labels: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None
@@ -1736,6 +1736,18 @@ class ResourceLinkModel(Base): # type: ignore[misc]
primary_key=True, primary_key=True,
) )
# Link type metadata aligned with spec DDL (line 45549).
# NOTE: Spec DDL defines link_type as nullable (TEXT DEFAULT 'contains'
# without NOT NULL). We enforce NOT NULL because a NULL link type is
# semantically meaningless and the sibling resource_edges table also
# requires a non-NULL link_type.
link_type = Column(
Text,
nullable=False,
default="contains",
server_default=text("'contains'"),
)
# Timestamp (ISO-8601 string) # Timestamp (ISO-8601 string)
created_at = Column(String(30), nullable=False) created_at = Column(String(30), nullable=False)
@@ -1744,6 +1756,14 @@ class ResourceLinkModel(Base): # type: ignore[misc]
"parent_id != child_id", "parent_id != child_id",
name="ck_resource_links_no_self_loop", name="ck_resource_links_no_self_loop",
), ),
# NOTE: Spec DDL (line 45549) does not declare a CHECK constraint on
# link_type (the allowed values appear only in a comment). We add this
# CHECK to match the sibling ck_resource_edges_link_type constraint and
# prevent invalid link types at the database level.
CheckConstraint(
"link_type IN ('contains', 'references', 'derived_from')",
name="ck_resource_links_link_type",
),
Index("ix_resource_links_child", "child_id"), Index("ix_resource_links_child", "child_id"),
Index("ix_resource_links_parent", "parent_id"), Index("ix_resource_links_parent", "parent_id"),
) )
@@ -2791,6 +2811,12 @@ class DecisionModel(Base): # type: ignore[misc]
Index("ix_decisions_type", "decision_type"), Index("ix_decisions_type", "decision_type"),
Index("ix_decisions_created", "created_at"), Index("ix_decisions_created", "created_at"),
Index("ix_decisions_plan_seq", "plan_id", "sequence_number"), Index("ix_decisions_plan_seq", "plan_id", "sequence_number"),
Index(
"idx_decisions_superseded",
"superseded_by",
postgresql_where=text("superseded_by IS NOT NULL"),
sqlite_where=text("superseded_by IS NOT NULL"),
),
) )
# --- Domain conversion --- # --- Domain conversion ---
@@ -3001,14 +3027,27 @@ class CheckpointModel(Base): # type: ignore[misc]
nullable=False, nullable=False,
) )
# Optional FK to decisions (decision-aligned checkpoints) # Optional FK to decisions (decision-aligned checkpoints).
decision_id = Column(String(26), nullable=True) # NOTE: Spec DDL (line 45569) uses bare REFERENCES without ON DELETE.
# We use ondelete="SET NULL" so that deleting a decision does not raise
# IntegrityError when checkpoint rows still reference it.
decision_id = Column(
String(26),
ForeignKey("decisions.decision_id", ondelete="SET NULL"),
nullable=True,
)
# Checkpoint type: pre_write, post_step, or manual # Checkpoint type: pre_write, post_step, or manual
checkpoint_type = Column(Text, nullable=False, default="manual") checkpoint_type = Column(Text, nullable=False, default="manual")
# Optional resource association # Optional resource association.
resource_id = Column(String(26), nullable=True) # NOTE: Same ondelete="SET NULL" rationale as decision_id above
# (spec line 45571 uses bare REFERENCES).
resource_id = Column(
String(26),
ForeignKey("resources.resource_id", ondelete="SET NULL"),
nullable=True,
)
# Sandbox reference (e.g. git commit hash) # Sandbox reference (e.g. git commit hash)
sandbox_ref = Column(Text, nullable=False) sandbox_ref = Column(Text, nullable=False)