Discovered before implementation: discard-and-rebuild cannot exist. Progress events, tasks, decisions and review contracts reference workplans with ON DELETE RESTRICT, and 1067 of 1075 workplans carry such a reference. Deleting a workplan would delete the hub-native records attached to it, which ADR-010 decision 4 forbids and the schema refuses. The database is enforcing this ADR's own boundary one layer down. A progress event is a fact that happened; destroying it to tidy a derived projection loses hub-native truth to fix a derived-state problem. Reset now reconciles: create what is missing, update what differs, retire what no longer derives — and retirement must be visible rather than silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
15 KiB
Markdown
273 lines
15 KiB
Markdown
---
|
|
id: CUST-ADR-012
|
|
type: architecture-decision-record
|
|
title: "What the Hub Projects: Forge as Projection Source, Working Copies as Preliminary Overlay"
|
|
status: accepted
|
|
owner: the-custodian
|
|
revision: "1.0"
|
|
last_reviewed: "2026-08-25"
|
|
review_interval: 6m
|
|
decided_by: Bernd Worsch
|
|
date: "2026-08-25"
|
|
tags: ["architecture", "state-hub", "forgejo", "projection", "provenance", "authority", "contributors", "adr-001", "adr-010"]
|
|
---
|
|
|
|
# ADR-012: What the Hub Projects — Forge as Projection Source, Working Copies as Preliminary Overlay
|
|
|
|
## Status
|
|
|
|
**Accepted 2026-08-25** by Bernd Worsch. Supersedes `ADR-010` decision 1's phrase "authoritative as a reading of
|
|
the repositories" by making the reading concrete, and implements decision 6's
|
|
unbuilt notion of "preliminary".
|
|
|
|
## Context
|
|
|
|
`ADR-001` says work originates as repository files and the hub is a read model.
|
|
`ADR-010` says central is authoritative *as a reading of the repositories*. Both
|
|
leave one question unanswered, and it turns out to be the load-bearing one:
|
|
|
|
> **Which** repository files? The forge, or a working copy?
|
|
|
|
Verified on 2026-08-25, the answer today is **neither**.
|
|
|
|
Central runs one FastAPI pod on railiance01 with a CNPG database. Inspected from
|
|
inside, it holds `/app` — its own source — and nothing else. No clone tree, no
|
|
repository mount; the `sweep` hostPath that would give it one is disabled. **The
|
|
hub never reads a repository.** It cannot see the forge, and it cannot see a
|
|
workstation.
|
|
|
|
What actually happens is the inverse of the ADR language: `fix-consistency` and
|
|
`registrar-reconcile` run *on a workstation*, read files in `/home/<user>/<repo>`,
|
|
and POST the result over a tunnel. The `host_paths` field records this plainly —
|
|
117 repositories carry a path on `bnt-lap001`, a laptop.
|
|
|
|
So the projection derives from **whichever checkout most recently ran the sync**.
|
|
That is a third thing, distinct from both the forge and any particular working
|
|
copy, and it is written down nowhere.
|
|
|
|
### Why this is not academic
|
|
|
|
Every failure investigated under `CUST-WP-0067` and `CUST-WP-0068` is downstream
|
|
of it:
|
|
|
|
- A local hub and central both served port 8000, and every default reached the
|
|
local one for seven weeks. Neither could be distinguished by what it projected.
|
|
- 50 repository records pointed at a retired forge, so the sync could not match
|
|
the checkout and silently registered nothing.
|
|
- Duplicate registrations accumulated: the same file registered twice under two
|
|
identifiers, because two environments each believed they were projecting it.
|
|
- `git_fingerprint` — the field that should say which commit a projection
|
|
reflects — still holds the *initial commit* for `the-custodian`, while
|
|
`last_state_synced_at` is minutes old. There is no reliable way to ask central
|
|
what state it is a projection *of*.
|
|
|
|
### The forcing function
|
|
|
|
With one contributor, "last writer wins" is invisible, because there is only one
|
|
writer. With several, each pushes a projection of a different repository state
|
|
into one shared read model, and each will look locally consistent while
|
|
disagreeing with the others. That is the peer-database problem `ADR-010`
|
|
diagnosed, relocated one layer up and made harder to see.
|
|
|
|
The window to fix this cheaply is before the second contributor, not after.
|
|
|
|
## Decision
|
|
|
|
**1. The forge is the projection source.** Central derives its projection from
|
|
Forgejo — the pushed state of the default branch — not from any working copy.
|
|
"Authoritative as a reading of the repositories" now names a specific reading:
|
|
what the forge holds.
|
|
|
|
Forgejo runs in the same cluster as central, so this is an in-cluster fetch. No
|
|
tunnel, no cross-network credentials, and central already carries a `git` binary.
|
|
|
|
**2. Every projected record carries the commit it derived from.** A projection
|
|
without provenance cannot be audited, and the existing `git_fingerprint` proves
|
|
the point by being wrong and silent about it. Records must reference the commit
|
|
that produced them, and a projection whose commit is older than the forge's head
|
|
must be visibly stale rather than quietly current.
|
|
|
|
**3. Unpushed local work is a preliminary overlay, never the baseline.** A
|
|
working copy may contribute records the forge does not yet hold. They are marked
|
|
preliminary, attributed to their environment, and never silently merged into the
|
|
derived baseline. When the commit that carries them reaches the forge, the
|
|
baseline absorbs them and the overlay entry retires.
|
|
|
|
This implements `ADR-010` decision 6, which named "preliminary" but left it
|
|
unbuilt.
|
|
|
|
**4. The overlay is a label, not a second store.** Preliminary records live in
|
|
the same projection, flagged. They are not a parallel database and not a local
|
|
hub. Every local projection this ecosystem has had was eventually mistaken for
|
|
authority; the defence is that there is nothing separate to mistake.
|
|
|
|
**5. A working copy is a workspace, not a source of authority.** Deleting a
|
|
checkout after pushing must lose nothing and invalidate nothing. Validation of a
|
|
projection must not require a local clone of the repository being validated.
|
|
|
|
**6. Push-based sync is retired as the primary path.** Workstation tooling stops
|
|
being how records reach central. It may continue to *propose* preliminary
|
|
records under decision 3, but the baseline is derived, not received. This closes
|
|
the gap where "central derives" was policy while "the laptop pushes" was
|
|
practice.
|
|
|
|
**7. The projection must be resettable from the forge, as a routine operation.**
|
|
There must be a supported way to reconcile the projection — for one repository or
|
|
for the whole fleet — against what Forgejo holds: create what is missing, update
|
|
what differs, and **retire what no longer derives**.
|
|
|
|
> **Amended 2026-08-25, before implementation.** This decision originally said
|
|
> *discard and rebuild*. That operation cannot exist, and the reason is a good
|
|
> one. `progress_events`, `tasks`, `decisions` and `review_contracts` all
|
|
> reference workplans with `ON DELETE RESTRICT`, and 1067 of 1075 workplans carry
|
|
> at least one such reference. Deleting a workplan would mean deleting the
|
|
> hub-native records attached to it — which `ADR-010` decision 4 forbids, and
|
|
> which the schema refuses to allow.
|
|
>
|
|
> The database is enforcing this ADR's own boundary one layer down. A progress
|
|
> event recording work on a workplan is a fact that happened; destroying it to
|
|
> tidy a derived projection would be losing hub-native truth to fix a
|
|
> derived-state problem, which is exactly backwards.
|
|
>
|
|
> Retirement achieves what reset is for — the projection converges on the forge,
|
|
> and records that no longer derive stop appearing as live work — without
|
|
> erasing anything that only the hub holds. Reset reconciles; it does not
|
|
> destroy.
|
|
|
|
This is the decision that makes the others checkable rather than merely stated.
|
|
A read model that cannot be rebuilt from its source is not a projection; it is a
|
|
database with a projection's reputation, and the difference only becomes visible
|
|
on the day someone needs to rebuild it. `ADR-010` decision 2 already asserts that
|
|
a cache "may be discarded and reconstructed from the repositories at any time" —
|
|
that claim has never been executed, and an untested rebuild path is an assumption,
|
|
not a capability.
|
|
|
|
Three properties make it real rather than ceremonial:
|
|
|
|
- **Routine, not emergency.** It should be run deliberately and often enough that
|
|
it is known to work, not discovered under pressure. A reset that has never been
|
|
performed is indistinguishable from one that does not work.
|
|
- **Per repository is the unit.** Not a convenience over a fleet-wide operation —
|
|
the repository *is* the unit of reconstruction, and the fleet-wide form is
|
|
defined as iteration over it.
|
|
|
|
This follows from the source. Each repository is a separate forge repository
|
|
with its own history and its own head commit, so its projection is derivable in
|
|
isolation and provable in isolation: fetch that repository, rebuild its
|
|
records, compare against that repository's head. Nothing about that requires
|
|
knowing the state of any other repository, and a design that made it require so
|
|
would be inventing a dependency the source does not have.
|
|
|
|
It also bounds the blast radius, which is what makes decision 7's "routine"
|
|
achievable. A rebuild that can only run fleet-wide is an operation nobody runs
|
|
casually, and therefore an operation nobody runs — which is precisely how
|
|
`ADR-010`'s never-executed reconstruction claim came to be believed. Scoped to
|
|
one repository, a rebuild is small enough to be ordinary, and ordinary is the
|
|
only state in which it stays known-working.
|
|
|
|
It is also the only form that composes with the refusals below. A repository
|
|
holding projection-only records can be held back and dispositioned while every
|
|
healthy repository around it is rebuilt; an all-or-nothing reset is blocked
|
|
entirely by a single bad repository, which in practice means it is blocked
|
|
permanently.
|
|
|
|
With contributors, per-repository scope is what keeps one person's rebuild from
|
|
touching another person's records.
|
|
|
|
The fleet-wide form must therefore be a loop over the per-repository form,
|
|
sharing one implementation. The rarely-used dangerous path and the frequently
|
|
used safe path must be the same code, so the rare one is exercised by the
|
|
common one rather than trusted on the strength of never having been run.
|
|
- **Idempotent and verifiable.** A reset followed by a reset produces the same
|
|
projection, and the result can be compared against the forge to show it matches.
|
|
Derived identifiers (`ADR-007`) are what make this possible: the same commit
|
|
yields the same record identities every time.
|
|
|
|
**Retirement must be visible, not silent.** A retired record states that the
|
|
forge no longer derives it, and remains inspectable — including from whatever
|
|
hub-native history is still attached. A record that merely disappears from a
|
|
listing is indistinguishable from one that was never there.
|
|
|
|
**Reset does not restore the preliminary overlay.** Overlay records exist
|
|
precisely because the forge does not hold them, so a rebuild from the forge
|
|
cannot reproduce them and must not pretend to. Reset therefore discards
|
|
preliminary state, and must say so plainly before it runs.
|
|
|
|
**A reset must refuse when records exist only in the projection.** If the hub
|
|
holds records with no counterpart in the forge, rebuilding destroys them. That is
|
|
not hypothetical: as of 2026-08-25, 111 work records existed only in a retired
|
|
local database, and a rebuild at that moment would have erased them. The reset
|
|
path must detect that condition and stop, naming what would be lost, rather than
|
|
proceed and report success.
|
|
|
|
The refusal is evaluated per repository, in keeping with the scope above. A
|
|
repository whose records all exist in the forge is rebuildable regardless of what
|
|
any other repository holds, and a fleet-wide run must skip and report the
|
|
repositories it refuses rather than abort the whole pass. Otherwise one
|
|
unresolved repository blocks reconstruction everywhere, and the capability decays
|
|
back into the untested assumption this decision exists to prevent.
|
|
|
|
**8. Formal git review stays optional.** Deriving from the default branch gives a
|
|
shared baseline without requiring pull requests. Review can be adopted per
|
|
repository where it earns its keep; this ADR neither mandates nor forbids it.
|
|
|
|
## Consequences
|
|
|
|
**Positive.** The hub becomes provably a projection: reconcilable on demand
|
|
against its source, and therefore knowable to be one. Truth becomes checkable by anyone, from anywhere,
|
|
without a clone.
|
|
Multiple contributors share one baseline instead of overwriting each other's
|
|
views. Provenance becomes auditable — every record can name its commit. The
|
|
"push then delete the working copy" case simply works. The distinction between
|
|
committed and uncommitted work becomes visible in the model rather than a matter
|
|
of who ran which command last.
|
|
|
|
**Negative.** Git becomes load-bearing for the hub: Forgejo availability now
|
|
affects projection freshness. A derive loop needs a cadence, and freshness
|
|
becomes a property to monitor rather than assume. Unpushed work becomes
|
|
explicitly second-class — which is its honest status, but it will feel like a
|
|
restriction to a solo developer used to local-first behaviour.
|
|
|
|
**Migration.** The `sweep` hostPath — central reading a node-local clone tree —
|
|
is a half-measure toward this decision and was disabled pending "governed remote
|
|
reconciliation" while railiance01 checkouts still targeted Gitea. As of
|
|
2026-08-25 all 79 node checkouts track Forgejo, so that stated blocker has
|
|
cleared. Sweep should be evaluated as a stepping stone or retired in favour of a
|
|
direct forge fetch, not left dormant with an obsolete justification.
|
|
|
|
**Unresolved.** This ADR does not settle the derive cadence, whether central
|
|
clones or uses the Forgejo API, how preliminary records are surfaced in the
|
|
dashboard and MCP, or what happens to a preliminary record whose commit never
|
|
arrives. Nor does it settle how hub-native records — progress events, decisions,
|
|
inbox messages, which `ADR-010` decision 4 classes as originating in the hub —
|
|
survive a reset. They are not forge-derived and must not be destroyed by a
|
|
rebuild of forge-derived state; the boundary needs drawing before reset is
|
|
built. Those belong to implementation.
|
|
|
|
## Relationship to prior decisions
|
|
|
|
- **`ADR-001`** — unchanged. Work still originates as repository files; this ADR
|
|
says which copy of them the hub reads.
|
|
- **`ADR-010`** — decisions 1 and 5 are sharpened, not reversed: central still
|
|
derives and still does not accept pushes of derived state. Decision 6's
|
|
"preliminary" gains a mechanism. The local-cache-versus-database framing stands.
|
|
- **`ADR-003`** — partially superseded. Decision 2 composes fingerprints from
|
|
filesystem `mtime`, which is a property of one workstation rather than of the
|
|
source; under decision 1 here the input is the commit. Decision 5 already
|
|
stated the rebuild principle correctly but had never been exercised; decision 7
|
|
here makes it an operation with a source, a scope and a verification.
|
|
- **`ADR-007`** — derived identifiers become more valuable here: a forge-derived
|
|
projection and a preliminary overlay compute the same identifier for the same
|
|
record, so absorbing an overlay entry needs no reconciliation.
|
|
|
|
## References
|
|
|
|
- `ADR-001` — workplans originate as repo files; hub is a read model
|
|
- `ADR-010` — hub authority, local cache, and the two kinds of hub data
|
|
- `ADR-007` — identifier uniqueness and derived identifiers
|
|
- `CUST-WP-0067` — hub target resolution; retired the impersonating local instance
|
|
- `CUST-WP-0068` — cache-only work-record recovery; surfaced the stale
|
|
`git_fingerprint` and the duplicate registrations
|
|
- Verification, 2026-08-25: central pod holds no repository files; `sweep`
|
|
disabled; 117 repositories record a laptop path; `the-custodian`
|
|
`git_fingerprint` is the initial commit while `last_state_synced_at` is current
|