First execution failed in a way that proved the design: the pre-upgrade hook applied both migrations and deleted itself on success, then the k3s tunnel dropped before the API could roll. Schema was briefly ahead of code — the safe direction, and why additive migrations were the right shape. Records two gaps it exposed: a successful migration leaves no trace because of the hook delete policy, and bridge status reported a dropped tunnel as connected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 2583210@bnt-lap001 Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
12 KiB
| id | type | title | domain | repo | status | owner | topic_slug | created | updated | related | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| STATE-WP-0083 | workplan | Forge-derived projection reset, per repository | infotech | state-hub | active | codex | infotech | 2026-08-25 | 2026-08-25 |
|
Forge-derived projection reset, per repository
Goal
Implement ADR-012 decision 7: discard a repository's projection and rebuild it
from what Forgejo holds. Per repository, routine, idempotent, and verifiable.
This is the only sanctioned way to remove a hub record. The hub has no
hard-delete for work records — DELETE /tasks/{id} is cancel_task,
DELETE /workstreams/{id} is 410 Gone, and no session.delete exists for
either. Re-derivation is not one option among several; it is the mechanism.
Why here and not in Repo Manager
ADR-012 decision 1 says central derives from the forge, and the pod can reach
it: git ls-remote https://forgejo.coulomb.social/... succeeds anonymously from
inside the cluster, and git is already in the image.
Driving the reset from Repo Manager would mean computing a projection on a
workstation and pushing it into central — precisely the injection of derived
state ADR-010 decision 5 forbids. Central must do its own reading. The cost is
that workplan parsing exists in two places; scripts/consistency_check.py
already parses frontmatter and task blocks here, so the capability is present
and should be extracted rather than rewritten.
What it must not do
- Not restore preliminary overlay records. They exist because the forge does not hold them; a rebuild from the forge cannot reproduce them and must not pretend to.
- Not destroy hub-native records. Progress events, decisions and inbox
messages originate in the hub (
ADR-010decision 4). They are not forge-derived and must survive a rebuild of forge-derived state. - Not proceed silently when records would be lost. Stop, name them, and require an explicit acknowledgement.
Derive a repository's projection from the forge
id: STATE-WP-0083-T01
status: done
priority: high
Given a repository slug, clone or fetch its default branch from Forgejo into a temporary location, parse the workplan files, and compute the projection that state would produce: workplans, tasks, their identifiers, statuses and backing paths.
Read-only and side-effect free. Record the commit the derivation came from —
ADR-012 decision 2 requires provenance, and the existing git_fingerprint is
worthless precisely because nothing ever wrote it correctly.
Acceptance: deriving the-custodian twice from the same commit yields identical
output, and the commit is reported.
Done (2026-08-25). api/services/forge_projection.py. Central clones the
default branch from Forgejo and derives 69 workplans and 459 tasks from
the-custodian at commit d5013ae, identically across runs. Identifiers are
derived from the canonical record id in the ADR-007 namespace, verified against
live records — so a forge-derived projection and a preliminary overlay agree on
identity without reconciliation. Eight tests, including that identifiers are
derived rather than inherited from whatever a file happens to carry.
A fresh shallow clone each time is deliberate: reusing a working copy is how a projection ends up reflecting someone's local state instead of the forge.
Report the difference before changing anything
id: STATE-WP-0083-T02
status: progress
priority: high
Compare the derived projection against what the hub currently holds for that repository, and report: records the forge has that the hub lacks, records the hub has that the forge does not, and records whose fields differ.
This is the half that is immediately useful without any destructive capability,
and it is what makes decision 7's "verifiable" real. It also answers the
question CUST-WP-0068-T09 is waiting on — which stale rows would actually
clear — before anyone commits to clearing them.
Acceptance: a dry-run diff for a repository with known drift matches what manual inspection shows.
Workplan-level diff done; task-level blocked (2026-08-25).
The first implementation matched hub records to derived ones by UUID and was
badly wrong: most hub records still carry pre-ADR-007 random identifiers, so
nearly every record appeared simultaneously missing and stale. A reset built on
that comparison would have destroyed and recreated the whole projection. It now
matches on canonical record id, falling back to the backing file.
Workplan-level results are trustworthy and inspectable:
| Repository | missing | stale | differing |
|---|---|---|---|
whitehat-security |
0 | 0 | 0 |
the-custodian |
3 | 4 | 52 |
railiance-platform |
18 | 25 | 2 |
whitehat-security reporting clean is the control: it was bootstrapped directly
from its files, so a forge derivation must agree with it exactly. The four stale
workplans on the-custodian were checked by hand and are real —
CUST-WP-0023/0024 have no file in the repository at all, and
state-hub-v0.1/v0.2 carry slugs that were never canonical ids. All four are
genuine hub-first records, the class ADR-010 says to disposition.
Blocked: hub tasks carry no canonical record id. The task schema is
id, workplan_id, title, status, priority, … with nothing holding
CUST-WP-0067-T01. A file task and a hub task can therefore only be matched by
title, which is why the task diff reports 149 missing and 131 stale for
the-custodian where the workplan diff reports 3 and 4. That is the matching
failing, not drift.
Task-level reset must not be built on title matching — renaming a task heading
would silently destroy and recreate its record. T03 is limited to workplans
until tasks carry their canonical id, which is T06.
Apply the reset transactionally
id: STATE-WP-0083-T03
status: todo
priority: high
Replace the repository's forge-derived records with the derived projection in one transaction: create what is missing, update what differs, remove what no longer derives. Hub-native records are untouched.
Refuse by default when removal would destroy a record with no counterpart in the forge; report what would be lost and require an explicit acknowledgement to proceed. That refusal is evaluated per repository, so one unresolved repository never blocks the rest.
Acceptance: reset twice produces the same projection; a repository holding records the forge lacks is refused with those records named; hub-native record counts are unchanged across a reset.
Fleet form as a loop over the repository form
id: STATE-WP-0083-T04
status: todo
priority: medium
The fleet-wide reset iterates the per-repository reset and shares its implementation, so the rarely-used dangerous path is exercised by the frequently used safe one. Repositories that refuse are skipped and reported, never aborting the pass.
Acceptance: a fleet dry-run reports per-repository outcomes including refusals, and completes despite them.
Retire what the reset replaces
id: STATE-WP-0083-T05
status: wait
priority: medium
Once reset is trusted, CUST-WP-0068-T09 can clear its stale rows — 4 workplan
rows from the prefix migration and 305 task rows across 44 workplans.
ADR-003 decision 2's mtime-based fingerprints should also be replaced by the
source commit at this point; ADR-012 invalidated that composition and the
replacement belongs with the provenance work in T01.
Acceptance: CUST-WP-0068-T09 closes; no fingerprint input depends on a local
filesystem.
Give hub tasks their canonical record id
id: STATE-WP-0083-T06
status: todo
priority: high
Hub task rows hold no canonical identifier — only a title — so nothing reliably
connects CUST-WP-0067-T01 in a file to its row. Every other record type has a
stable identity; tasks do not, and that gap is what stops the reset from covering
them.
Add the canonical id to the task record and populate it during derivation and
registration. Once present, task matching becomes identity-based like workplans,
and T03 can extend to tasks safely.
Until then a task's identity is its title, which changes whenever someone edits a heading. That is not a foundation for deletion.
Acceptance: task rows carry their canonical record id; the task diff for
whitehat-security reports clean; the-custodian's task diff falls to something
manual inspection confirms.
Restore a migration mechanism for central
id: STATE-WP-0083-T07
status: done
priority: high
Central's schema is behind the code it is running, and nothing detects it. Discovered 2026-08-25 while preparing the retirement columns.
Central is at revision b8d4f0a2c6e1; the chain is
b8d4f0a2c6e1 → c9e5a1b3d7f2 → d1a2b3c4e5f6. The review_contracts table does
not exist on central even though the migration creating it ships inside the image
currently serving traffic. The code and the database disagree silently.
There is no migration mechanism. The image CMD is bare uvicorn, the Helm
chart declares no migration job, and the only evidence migrations ever ran was an
ad-hoc state-hub-alembic-init Job created outside the chart — deleted earlier
the same day as a retired-forge leftover. Deleting a completed Job removed a
historical record rather than a working mechanism, since Jobs do not re-run, but
it also removed the last visible sign that this gap existed.
This blocks T03: the retirement columns cannot reach central without it.
Needed: migrations as a declared part of the release — a chart-managed job or
init container that runs alembic upgrade head before the API serves — and a
check that surfaces a schema/code mismatch instead of leaving it silent. A
deployment that can run against a schema it was not built for is the same class
of defect as a projection that cannot name its source commit.
Acceptance: central reaches head; review_contracts exists; the mechanism is
declared in the chart; a mismatch between code and schema is reported rather than
tolerated.
Done (2026-08-26). Central reports
schema: {status: ok, applied: d1a2b3c4e5f6, expected: d1a2b3c4e5f6}.
review_contracts exists. Both pods run main-97c8762.
The first execution is worth recording, because it failed in a way that proved
the design. The pre-upgrade hook ran, applied both migrations and deleted
itself on success — then the k3s API tunnel dropped (unexpected EOF →
connection refused) and Helm could not read the deployment to roll the API.
The release wedged at pending-upgrade; no pod rolled.
The schema was therefore briefly ahead of the running code, which is the safe direction and the reason additive migrations were the right shape: the old image served correctly against the new schema throughout. A destructive migration in the same circumstance would have taken the service down.
Recovery was helm rollback to the last deployed revision — which clears the
wedge without touching the schema, there being nothing to undo — then re-running
the upgrade. The hook is idempotent, so the second run's alembic upgrade head
was a no-op.
Two things this exposed, neither yet addressed:
- The hook's
hook-succeededdelete policy removes the Job on success, so a successful migration leaves no trace. That made it look as though nothing had run when in fact everything had. Retaining a short-lived record of successful migrations, not only failed ones, would have answered the question immediately. bridge statusreportedk3s-api-railiance01asconnectedwhile holding a stale pid, so its health signal did not reflect the dropped connection. A tunnel monitor that cannot detect the failure it exists to detect is the same defect class as a projection that cannot name its source commit.