feat: converge mixed identifier projections
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
This commit is contained in:
tegwick 2026-08-31 01:27:21 +02:00
parent 6f64f1aab8
commit 151fdcf397
5 changed files with 256 additions and 25 deletions

View file

@ -204,6 +204,8 @@ async def test_repository_migration_cascades_and_reverses(test_engine):
result = await apply_repository_identifier_migration(session, plan, "test-repo")
assert result.direction == "forward"
assert result.replacements == 2
assert result.migrated == 2
assert result.already_derived == 0
assert result.assignments_deferred == 1
async with factory() as session:
@ -277,6 +279,103 @@ async def test_repository_migration_cascades_and_reverses(test_engine):
assert all(alias.reversed_at is not None for alias in aliases)
@pytest.mark.asyncio
async def test_repository_migration_accepts_mixed_legacy_and_derived_projection(test_engine):
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
ids = await _seed_projection(factory, "mixed-repo")
plan = _sealed_plan("mixed-repo", ids["workplan"], ids["task"])
task_new = _derived("TEST-WP-0001-T01")
# Forge reconciliation reached the task before the sealed repository
# migration. The parent remains legacy, so the repository is genuinely
# mixed rather than already migrated as one unit.
async with factory() as session:
await session.execute(
text("UPDATE tasks SET id = :new_id, record_id = :record_id WHERE id = :old_id"),
{
"old_id": ids["task"],
"new_id": task_new,
"record_id": "TEST-WP-0001-T01",
},
)
await session.commit()
async with factory() as session:
result = await apply_repository_identifier_migration(session, plan, "mixed-repo")
assert result.replacements == 2
assert result.migrated == 1
assert result.already_derived == 1
async with factory() as session:
aliases = list((await session.execute(select(WorkRecordIdentifierAlias))).scalars())
assert len(aliases) == 2
assert all(alias.migration_status == "applied" for alias in aliases)
# Retry is a verified idempotent convergence, not a conflicting target.
async with factory() as session:
retried = await apply_repository_identifier_migration(session, plan, "mixed-repo")
assert retried.migrated == 0
assert retried.already_derived == 2
@pytest.mark.asyncio
async def test_repository_migration_rejects_neither_legacy_nor_derived(test_engine):
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
ids = await _seed_projection(factory, "missing-repo")
plan = _sealed_plan(
"missing-repo", ids["workplan"], ids["task"], task_source_override=uuid.uuid4()
)
async with factory() as session:
with pytest.raises(IdentifierMigrationError, match="neither legacy nor derived task"):
await apply_repository_identifier_migration(session, plan, "missing-repo")
async with factory() as session:
assert await session.scalar(
text("SELECT count(*) FROM work_record_identifier_aliases")
) == 0
@pytest.mark.asyncio
async def test_identifier_migration_http_apply_and_reverse(client, test_engine, monkeypatch):
from api.routers import identifier_migrations
monkeypatch.setattr(
identifier_migrations.settings, "state_hub_instance_role", "primary"
)
monkeypatch.setattr(
identifier_migrations.settings, "state_hub_instance_label", "railliance01"
)
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
ids = await _seed_projection(factory, "http-migration-repo")
plan = _sealed_plan("http-migration-repo", ids["workplan"], ids["task"])
body = {
"plan": plan,
"expected_plan_sha256": plan["plan_sha256"],
"primary_confirmed": True,
}
response = await client.post(
"/identifier-migrations/repositories/http-migration-repo/apply", json=body
)
assert response.status_code == 200, response.text
assert response.json()["result"] == {
"repo_slug": "http-migration-repo",
"plan_sha256": plan["plan_sha256"],
"direction": "forward",
"replacements": 2,
"migrated": 2,
"already_derived": 0,
"assignments_deferred": 1,
}
response = await client.post(
"/identifier-migrations/repositories/http-migration-repo/reverse", json=body
)
assert response.status_code == 200, response.text
assert response.json()["result"]["direction"] == "reverse"
@pytest.mark.asyncio
async def test_repository_migration_accepts_protected_prior_slug(test_engine):
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
@ -340,7 +439,7 @@ async def test_repository_migration_is_atomic_when_a_source_is_missing(test_engi
)
async with factory() as session:
with pytest.raises(IdentifierMigrationError, match="source task"):
with pytest.raises(IdentifierMigrationError, match="neither legacy nor derived task"):
await apply_repository_identifier_migration(session, plan, "atomic-repo")
async with factory() as session: