ADR-097 — GitHub Projects v2 ↔ native board sync
- Status: accepted (2026-09-05)
- Serves: epic #613 — plug a cloud team onto the Iterion project board
- Relates to: AGENTS.md (the GitHub board is the roadmap truth, the native board is the bots' operational surface), ADR-046 (trigger spine), ADR-094 (durable effect outbox), ADR-096 (board claim lease), docs/native-tracker.md, docs/repo-scope.md
Context
AGENTS.md makes the GitHub Projects v2 board the truth for ongoing work, and keeps the iterion native board as the bots' operational surface (auto-triage, dispatch, claim/lease). Today the two are joined by exactly one thread: iterion issue import mirrors a repo's issues onto native cards, one way, idempotently (pkg/server/board_forge.go:syncForgeIssuesToBoard).
That thread carries none of the board itself. A Projects v2 board is not repo data: its Status, Area, Mode and Priority fields live in a project object, reachable only through the GraphQL API, and no seam in iterion speaks GraphQL to a forge at all (pkg/forge/github is REST-only; the only mention of GraphQL in the tree is a comment in ci.go explaining what REST cannot do).
The consequences are concrete and daily:
- A human moves #613 to In progress on the board; the dispatcher, which reads native columns, never learns it.
- A bot moves a native card to
done; the board still shows Planned, so the roadmap view lies until someone drags a card by hand. - The import lands every open issue in the same column, because the issue's
open/closedstate is all it can see. Inbox vs Planned vs Blocked — the distinction the methodology is built on — is invisible to it.
So the engine cannot serve the very contract AGENTS.md asks agents to follow.
Decision
Add a provider-neutral project-board capability to pkg/forge, implement it for GitHub Projects v2 over a new GraphQL client, and join it to the native board through one two-way field (Status) and nothing else.
1. Content flows ONE way: GitHub issue → native card
Title, body, labels, assignees, open/closed keep flowing forge → board, through the existing syncForgeIssuesToBoard. Nothing in this ADR ever writes an issue's content back to GitHub.
Rejected: two-way content sync. It needs per-field conflict resolution on free text, and the loser of a conflict is a human's paragraph. The board's job is to route work, not to be a second authoring surface; ADR-074 already refused a mutable per-bot projection for the same reason. Push-to-forge for a card authored on the native side stays what it is today — an explicit, operator- triggered gesture (POST /api/v1/native/issues/{id}/push), not a sync.
2. Status is TWO-way, over an injective map the operator can replace
The default vocabulary, which is what board 203 and the shipped native board already agree on:
Projects v2 Status | native state |
|---|---|
Inbox | inbox |
Planned | ready |
In progress | in_progress |
Blocked | blocked |
Done | done |
A native transition into a mapped state writes the item's Status field; a Status change on GitHub moves the card.
Those five names are a default, not a fence. A board whose columns read Todo / Doing / Shipped binds by naming them — iterion remote board bind … --status-map "Todo=ready,Doing=in_progress,Shipped=done", the same field on the binding API, tracker.github.project.status_map in a dispatcher config. The effective map is stored on the binding and rendered by board show, so what a deployment actually runs is always readable. One builder (forge.StatusMappingFromMap) serves every entry point, because three copies of a validation rule is how the third one drifts.
The map must stay injective in both directions, and a bind that maps two columns onto one state is refused, naming the collision: the reverse direction would otherwise be ambiguous — the reflect would pick one name and the next import would read the other back and undo the transition.
Native states outside the map (backlog, waiting_deps, awaiting_input, review) are inert: the reflect logs the skip once and writes nothing.
Rejected: collapsing the unmapped states onto their nearest neighbour (review → In progress). It makes the round trip lossy — the next import would read In progress back and drag a card out of review into in_progress, undoing a bot's own state machine. An honest no-op leaves the GitHub board showing the last true thing it was told.
Status option names are matched case-insensitively on the trimmed name, so a board that writes In Progress binds the same as one that writes In progress. A board missing one of the mapped columns is bound anyway: the missing rows are dropped and named in the bind result, rather than refusing the binding.
3. Area / Mode / Priority are read into labels, written back never
Each single-select field the board carries beyond Status is imported onto the card as a namespaced label — area:<value>, mode:<value>, prio:<value>, slugified (lowercased, spaces → -). They are read-only: the import writes them, nothing else does, and no iterion path ever sets those fields on GitHub.
They are declared board-local (boardLocalLabelPrefixes in pkg/server/board_forge.go, alongside triage: / needs: / cmd: / source:). Without that, the next plain iterion issue import — which mirrors the repo's labels verbatim and keeps only board-local namespaces — would silently strip every project-derived label off every card.
Rejected: modelling them as native custom Fields. Native fields are typed and board-scoped; a bot's label matcher (all_labels on a board invocation, the dispatcher's include/exclude lists) reads labels, not fields. Labels make area:cloud/ops immediately usable as a trigger predicate with zero new engine surface — which is the whole point of importing them.
4. The binding lives at the TEAM level
One document, forge.BoardBinding, keyed on the team (the resource tenant, ADR-048):
TeamID, Provider, Owner, OwnerKind (org|user), ProjectNumber,
ConnectionID (the forge.Connection supplying the token)
StatusMapping []{Status, State} the EFFECTIVE map (§2), stored so what
a deployment runs is always readable
SyncEvery the reconciliation interval (0 = off, default 2m — §10)
ProjectID, ProjectTitle, StatusFieldID, StatusOptions map[state]optionID,
LabelFields []BoundField{FieldID, Name, Prefix}
BoundAt, UpdatedAtRejected: per-repo binding (on forge.RepoIntegration). A Projects v2 board spans repos by design — board 203 tracks engine, bots, cloud/ops, studio and docs work that lands in several repos — so a per-repo binding would either duplicate the same project N times or force one repo to be "the" board owner. The team is the tenant every store already keys on, and one team ↔ one roadmap board matches how the methodology is actually used.
Rejected: binding by project URL string. owner + number is what both the GraphQL query and the human URL are built from, and it survives a project rename; the URL does not.
5. Field and option ids are DISCOVERED by name at bind time, then cached
PVTSSF_lADOAh0HH84BiOg8zhhHUgk is not a name anyone can type, is not stable across projects, and would be a hardcoded constant tying the engine to one board — exactly the coupling the ENGINE-stays-bot-agnostic rule forbids. So BindBoard resolves the project by owner/number, reads its fields, matches Status / Area / Mode / Priority by name, and stores the ids it found on the binding.
The cache is a cache, never an authority — and it is TWO caches that go stale independently:
- the option ids are what every write uses. They survive a rename and die with a delete;
- the column names (the stored
StatusMapping) are what both directions COMPARE — the import maps a board status onto a state, the reflect checks the state's status against the board's. They survive a delete-and-re-add and die with a rename.
So each edit an operator makes to a column breaks exactly one half. A rename leaves a perfectly valid id under a name nothing matches: the import goes inert and the reflect resolves the same still-valid option, writes it — a no-op on the forge's side — and records the old name, on every card, on every pass, indefinitely.
Every pass therefore re-resolves both halves against the project schema it has already read for the label fields, so the repair costs no API call (BoardBinding.ReconcileStatusOptions). Per mapped state, in order: the cached id still on the field ⇒ adopt the board's current name (rename repaired); else the mapped name still on the field ⇒ adopt its id (re-created column, or one added since the bind); else lost — unless the column has never resolved for this binding, which is the accepted partial coverage below. The Status field's own id is re-resolved the same way, by name.
A LOST column is the only unrepairable case, and it becomes an explicit state rather than a retry loop (the pluginsource quarantine precedent): those cards are counted reflect_no_column and skipped, and the binding carries a degraded_reason NAMING the column — surfaced on GET /api/teams/{id}/board-binding and logged once, on the transition, not on every pass. It is a partial outage: every column that still resolves keeps syncing. It clears itself when the column reappears, and a re-bind clears it too — which is what makes "re-bind the board" a real remedy rather than the only one.
That readout is a LEVEL, re-derived from the binding's current shape on every pass — never an event. "Lost" is defined as a mapped column the board serves under neither its cached option id nor its name, and that has NEVER resolved for this binding; only an EMPTY lost set clears the flag. Three consequences shape the code:
the bind-time accepted set is recorded separately (
unresolved_at_bind, written byBindBoardand — save the one-off reconstruction below — never by a reconciliation, unlikemissing_statuses, which every pass recomputes as "what the board lacks right now"). It is what distinguishes this column was never there from this column broke. Only something that worked can break: a three-column board bound with the five-column default map is partial coverage, not a degradation;"never resolved" is a CONJUNCTION, and the rule may not rest on either oracle alone — the bind accepted the column as absent, AND the binding still holds no option id for it. Each half covers what the other misses.
The accepted set alone never discharges. A column absent at bind and later ADOPTED earns a real option id and starts taking cards; its next disappearance is a genuine break, which a rule keyed on the bind-time NAME would go on exempting forever — the binding reading healthy while every card of that column is refused.
The cached id alone misreads the upgrade. The first release to ship this deleted that id on the pass that observed the loss, so a binding degraded by it has no id, no column, and a standing
degraded_reason— "the binding holds an id" finds nothing lost there, and a readout that clears when it finds nothing lost would clear a still-true degradation permanently, since the id never comes back. The same two shapes meet during a rolling deploy, in either order.A binding stored before
unresolved_at_bindexisted has the set RECONSTRUCTED from the binding, never from the live board — a mapped column the binding holds no id for is one the bind could not resolve. Reading the board instead would fold a column that broke in the upgrade window into the accepted set, and would persist "the operator accepted all five columns" the day theStatusfield itself vanishes. The reconstruction is asymmetric on the binding's own health: a healthy one accepted every column it never resolved; a degraded one accepted nothing, because from the outside its two possible histories are indistinguishable and the only safe reading of "I cannot tell" is to keep the degradation it declared. A health flag is never cleared by the absence of the evidence that would have kept it. The stored shape therefore has to tell an empty set from an unrecorded one — which is why the field is persisted even when empty;the dead option id is nevertheless KEPT, as the pass's
LostStatesset — what refuses the reflect for those cards — and as the half of the conjunction that keeps the loss re-derivable once the bind-time exemption has discharged.
The repaired vocabulary is persisted through a store method narrow enough to touch nothing else (SaveStatusVocabulary): a reconciliation may correct what the forge changed under it, never the address, credential or policy the operator chose. A pass with no binding store (the local one-shot iterion issue import --project) still repairs in memory — it converges, it just does not remember.
Because the reflect ultimately WRITES an option id, it also compares one: when the item already carries the option about to be written, nothing is written, whatever the names say. The name comparison remains as the fallback for a provider that reports no option id.
6. The project import HYDRATES cards; it never creates them
A project item carries a title, a URL and its field values — no body, no labels, no assignees, and no author. A card built from one would be degraded, and worse, would enter the board without passing the author-trust gate that runs at issue ingest and decides whether a card may spend LLM budget at all. So the project pass joins onto cards the issue import created, and counts the items it could not join (skipped_no_card) instead of inventing them.
Consequence for the operator: run the issue import for each repo the board spans, then the project pass. iterion issue import --project does both in one command for one repo.
The skip is actionable, not a number. The result carries missing_repos — the distinct owner/repo of the skipped items with a count each, most-missing first — on the CLI output and the API response. "12 skipped" tells an operator nothing they can act on; "8 in SocialGouv/iterion, 4 in SocialGouv/infra" is the next two commands.
7. A person's move out of a sink is the reopen; a machine's never is
Leaving a Terminal: true native column is a reopen — an operator surface op with a dependents check and an audit trail, and the native board's guard (ValidateStateExit) refuses it to every automated writer, deliberately: the silent resurrection of a closed card was the failure that guard exists for.
Amended (issue #839). The first shipping rule was "the import does not carve an exception", and production disproved it: an operator moved a card from Blocked to Inbox on the roadmap board, and every pass since logged a refusal while the two boards diverged for ever. The sink protects a card from a MACHINE — the watchdog, a sweep, a stale event. A drag on the roadmap board is not a machine; it is the operator's hand, arriving through the only channel they have. Dropping it was not conservatism, it was losing an instruction.
So the rule now distinguishes WHO moved the card and WHICH sink it left:
- A person's move out of a non-completion sink is honoured, through
Reopen— the sanctioned exit, with its own dependents check and its own audit marker — never through the automated write the sink refused. Counted asreopened_terminal(its own bucket, disjoint frommoved) and stampedreopened_aton the card's sync record. Nothing consumed a parked card's state: onlyStateDonesatisfies a dependent's hard blockers (BlockerSatisfied), which is exactly the linenative.ReopenableByBoardMovedraws. - A move out of the COMPLETION column is still refused. A done card may have promoted dependents whose launch consumed its completion — the case
ReopenBlockedByDependentsrefuses one card at a time — and that arbitration cannot be taken on a board the promoted work does not appear on. Reopening finished work stays a native gesture. - "A person" is not a guess. The pass honours a move only where it can attribute it:
projectStatusApplyover a RECORDED status — the pass's own "only the board moved" arm, where the status iterion last synchronized still maps to the card's column, so nothing but a hand on the board changed anything. That is the same oracle the reflect direction uses to answer "who moved?", so the two directions cannot disagree. A contested move (both sides moved) is refused: something else moved the card, and a board that cannot see what did must not arbitrate it into a resurrection. A first sight is refused too — a card with no recorded status has nothing that CHANGED, and without that rule binding a board would drag every parked card out of its column at once. - The sink itself is untouched.
ValidateStateExitis unchanged, and the pass consults it by TRYING the ordinary CAS rather than re-deriving the rule — a second copy would drift the day a board declares another terminal column. Every other writer (SetState,SetStateFrom, the owned family, a bot'sboard.move) meets it with no exemption.
A refusal is a fact in the tool, not a log line. The symptom of the old rule was "I moved it and nothing happened", with the explanation in kubectl logs. A refused move is now written on the card (ExternalProject.SyncConflict: from/to, the board column, the item, when it was FIRST observed) and on the binding's health (SyncConflictReason / SyncConflictAt, a second readout beside DegradedReason — separate because that one is recomputed level-triggered from the status vocabulary on every pass and would clear a refusal it never looked at). Both are level-triggered: the pass that no longer meets the refusal clears them, so the readout describes the board now rather than the worst thing that ever happened to it. The card's record keeps its FIRST timestamp across repeats, because a re-stamped one would rewrite the card every tick — bumping UpdatedAt and emitting the card.updated the trigger spine relaunches subscriptions on.
8. Idempotency: the card id IS the key
The existing deterministic card id stays the only key: forgeCardID(provider, repo, number) = native: + UUIDv5 over "<provider>:<repo>#<number>". A project item whose content is SocialGouv/iterion#613 therefore addresses exactly the card the issue import already created or will create — the two importers converge on the same row with no shared bookkeeping.
Per-card sync state hangs off native.ExternalRef.Project:
Owner, Number the bound project this card is synced with
ItemID the provider's project-item node id (skips a lookup)
Status, StatusAt the Status option NAME last synchronized, and the
provider's own timestamp for that value
StateAt when the native state last changed, per iterionRejected: a separate mapping collection. Two rows to keep consistent, and a card deletion that leaks a row. ExternalRef is already the card's external identity, already round-trips through both the FS store and the Mongo twin, and already survives the import's patch path.
9. Conflict rule: newer wins, GitHub wins ties, always logged
Both directions carry a timestamp of the state change (not of the record): GitHub's ProjectV2ItemFieldSingleSelectValue.updatedAt, and iterion's native.Issue.StateAt.
Issue.StateAt is stamped by the store at every state write, on both twins (the FS store derives it in writeIssueLocked from the state differing from the indexed record; the Mongo store in stateSetAt and in the state-naming replace). It has to be the store's, not this sync's: a card moves from the studio, the dispatcher, a board MCP tool and the trigger spine, and a stamp only this package wrote would have under-dated every one of them and lost them all. It is not UpdatedAt either — that bumps on any edit, so a retitle would win a status conflict. A card whose last transition predates the stamp falls back to ExternalRef.Project.StateAt (when iterion last wrote its state for this board), which is what the rule read before.
- Value already equal ⇒ nothing happens. This is the echo suppressor, and it is checked first, in both directions: a Status the reflect just wrote reads back as "already equal" at the next import, so a write can never ping-pong.
- Only one side moved ⇒ no conflict, and no timestamp is consulted. The native side is unmoved while the card still sits in the state the RECORDED status maps to — that mapped state is iterion's own last write, so a card still there has not moved. The oracle is that fact, not a timestamp comparison:
Issue.StateAtis bumped by any move, including a move away and back, so "the card's transition is newer than the board's" does not mean the card is anywhere other than where iterion last put it. A one-sided board move is therefore a plain apply and a one-sided native move a plain reflect. A recorded status the mapping does not cover leaves the question undecidable (iterion never derived a state from it) and is treated as moved — a phantom conflict costs oneWarn, the reverse silently overwrites somebody's decision. - Both sides moved since the last sync ⇒ the newer timestamp wins.
- Timestamps equal ⇒ GitHub wins, because it is the roadmap authority a human is looking at.
- Every applied conflict resolution is logged at
Warnwith both timestamps, both values and the winner — a silent overwrite of somebody's decision is the one outcome that must never be invisible.
Rule 2 is what keeps Conflicts meaning what §9.2 says it means. Without it a human's drag — the ordinary gesture on a project board — counted as "both sides moved", and whenever the card's transition happened to be the newer of the two, the phantom conflict resolved in the native side's favour and the reflect pushed the card's OLD column back over the drag.
The native write is a CAS (SetStateFrom(id, seen, want)), so an operator who moved the card between our read and our write wins over the stale fact we were carrying, exactly as the issue import already behaves.
Archived items are off the board, and the two readings differ on purpose. The forge removes an archived item from every view while PRESERVING its field values, so one archived in a candidate column keeps reading as that column forever. BoardClient.ListProjectItems flags rather than filters them, and each caller decides:
- the sync pass skips them, counted as
skipped_archived— importing would drive a card from a column nobody can see, reflecting would write into a row nobody can read; - the dispatcher's candidate filter skips them too: dispatching one launches a bot, and spends LLM budget, on work the operator visibly removed;
- the dispatcher's liveness read (
RefreshStates) still reports them. Omitting an id there is how the dispatcher learns an issue disappeared, and it answers by cancelling the run and reaping its slot. Archiving is a tidy-up gesture, not a documented kill switch, so a run in flight survives someone clearing the board behind it.
10. The reflect is the second direction of ONE reconciliation pass
Both directions run in the same pass, on the same board read, elected per tenant. For each item the pass asks one question — does the board still say what iterion last recorded? — and that single comparison is simultaneously the who-moved oracle and the echo suppressor:
- the board's status differs from the recorded one ⇒ the board moved ⇒ the import arm applies §9's conflict rule. When the board wins, the card follows it and nothing is pushed. When iterion wins (its state change is newer), the pass pushes instead — that is the case the conflict rule exists for, and the recorded status is deliberately left stale until the push overwrites it with what was actually written;
- the board matches the record, but the card's column maps to a different status ⇒ nobody but iterion put that status there, so the divergence can only be a native move ⇒ write the
Statusfield; - first sight (nothing recorded) ⇒ import only: the board is the authority on the join, and pushing would overwrite a column nobody has reconciled yet;
- unmapped native state ⇒ inert (§2).
A native-wins conflict is the one case that does not advance the recorded status inline: the reflect writes it with what it actually pushed. When the reflect pushes NOTHING — the two sides already landed on the same column, the native state is unmapped, the bound board has no column for it, the pass is read-only — the import records what it OBSERVED instead, so a divergence nothing can resolve is derived once rather than warned and counted on every tick. A failed write is deliberately excluded: the stale record is what makes the next pass retry it.
Because the recorded status advances on every write, a pass with nothing moving writes nothing — the property that keeps the loop from re-pushing forever and stamping a fresh updatedAt that would then win every conflict against the operator.
Owner and election. BoardSyncWorker ticks every 30s, takes each binding whose own sync_every is due, and CAS-advances that binding's watermark while taking a lease on it (sync_lease_until, TTL forge.BoardSyncLeaseTTL = 5 min); only the winner runs the pass. No in-process global is the authority and N replicas are correct.
The lease is the half the watermark cannot give. A pass slower than the binding's interval (floor 1m) makes the board due again while it is still running, and the next tick presents exactly the watermark that pass wrote — so the CAS matches, and two replicas reconcile the same board at once, issuing duplicate SetSingleSelect calls and duplicate External writes on the same cards. With the lease, the second one loses without advancing the watermark.
The release is a CAS on the pass's own owner token (sync_lease_owner, fresh per pass). Without it, a pass that overran the TTL would clear the lease of the successor that legitimately took the board, re-admitting exactly the concurrent pass the lease refuses; with it, the late release is declined and reported (ErrBoardSyncLeaseLost → one Warn naming the overrun), which is the only moment an overrun is knowable at all.
The TTL is a backstop, not the normal release: runPass hands the board back when it ends, whatever the outcome, so the TTL only ever fires for a replica that died mid-pass — bounding that death to one TTL of staleness rather than a board nobody may claim again. Deliberately not heartbeated (unlike ADR-096's per-card claim lease): a reconciliation net is not a run, and a lease it must refresh is machinery a five-minute ceiling buys nothing over. Each pass logs exactly one line: an Info with the counters (items, moved, reflected, labelled, conflicts, refused_terminal, reflect_failed, skipped_no_card) and its duration, or a Warn naming the failure — which never blocks the next tick, since a persistently failing board must not pin the sweep. One tenant's revoked token skips that tenant, not the sweep.
Cost and cadence. sync_every defaults to 2 minutes (floor 1 minute, 0 = off, refused below the floor rather than clamped). Ten minutes was rejected as the default: a roadmap lagging a bot by ten minutes reads as broken to the human watching it. The price is one project read per bound team per interval — GitHub prices a Projects v2 page at a handful of points against a 5000/hour budget, so a few-hundred-item board costs well under 1% of it.
Locally the same pass is the operator's to run: iterion issue import --project by hand, or wired into iterion schedule.
Rejected: a third arm of trigger.Evaluator.applyEffect — the shape this ADR first proposed, before the code was read. Three facts killed it:
EffectRowcarries no effect-kind, andMaterializeEffectsonly creates rows for matched subscriptions. A projection reaches the cloud outbox only by BEING a subscription, or by an expand/contract migration of a durable schema.- Being a subscription means a new
bundle.ExecutionMode— a bot-manifest vocabulary — for a row with no bot, which/api/v1/triggerswould list and an operator could delete, silently killing the reflect. matchingSubscriptionsdeclines machine-caused events in the shared prelude, before the mode switch. A projection must NOT be declined (a watchdog filing a card inblockedis exactly what the roadmap must show), so it would require editing the admission path that protects the fleet from mass launches — the riskiest line in that file.
The pass form needs none of that, and it is the reconciliation net rather than a second mechanism beside one.
Rejected: a standalone reflect worker subscribed to the bus. In cloud it would receive nothing (board events left the bus at ADR-094), so it would need either a re-publish — reintroducing a lossy path that then owes its own sweep — or a second poll-tail with a second per-tenant cursor.
Named follow-up. If sub-minute reflect latency is ever wanted, the clean path is an effect-kind on EffectRow (expand/contract) with the projection as a first-class effect — not a pseudo-subscription. The pass stays the net underneath it either way.
Addendum (issue #746) — the follow-up shipped; the pass stays the net
The effect kind is in: EffectRow.Kind is launch (the zero value, so every row written before it — and by every replica mid-rollout — reads as one) or projection. A card.moved event on a tenant that has a BoardBinding materializes ONE projection row, and Evaluator.applyEffect gained the arm that executes it through reflectNativeState — the same reflect, one implementation, two callers. Reflect latency is now the outbox's (seconds), not sync_every's.
Each of the three objections above was answered rather than waived:
- No effect kind — added, expand/contract, both twins, conformance covering a RAW pre-discriminator document. No version integer: the outbox is a store, not a negotiated wire, and the rollout doc's own test — "what does a replica that never sees the field do instead, and is that safe?" — answers treats it as a launch, finds no subscription, retires it, i.e. degrades to yesterday's latency with the pass still underneath. It cannot fail open.
- Would have to BE a subscription — it is not one. No
ExecutionMode, an EMPTYsub_id, and a row key using a separatorEffectIDdoes not, so/api/v1/triggerscannot list it and no operator DELETE can kill the reflect. Tested: materializing one creates no subscription, and the worker reaches the arm through a subscription store that fails onGet. - The machine-caused decline — the admission path was not edited. The projection is computed before
matchingSubscriptionsand dispatched before the decline inapplyEffect, at one line each, with the reason stated there. Launch admission is untouched, and a watchdog filing a card inblockedreaches the roadmap in seconds instead of two minutes.
What the fast path cannot do is verify the reflect's precondition ("the board still says what iterion last recorded") — it issues no board read. So it checks the only thing it can: the column the card left. Mapping to the recorded status means the two sides agreed right up to this move, and the divergence is the move. Not mapping means somebody moved the card on the board since the last pass; the projection declines and leaves the record stale, which is exactly what makes the pass re-derive and arbitrate §9's conflict with real timestamps. A previous state the map does not carry is inert (§2), so it is not a divergence and the reflect proceeds.
The other thing a board read would have told it is which columns the board still carries, and §5's repair makes that answerable without one: a lost column KEEPS its cached option id, so the option map alone would hand the fast path a dead id to fire at the forge — once per move of every card in that column, each burning an outbox row's whole retry budget for a column no retry can bring back. Both callers therefore build the reflect's column knowledge through one constructor, whose lost set is a UNION: the binding's persisted missing_statuses (all a path with no board read can know, and enough) plus, for the pass alone, the live repair it has just computed. nil there means "this call observed nothing about the board" — never "the board is fine". A card the fast path cannot reflect is counted, logged once for that event, and its row RETIRES rather than retries.
The pass is unchanged and remains the net. Both orders are idempotent and tested as such: after a projection the next pass writes nothing (to the forge or to the card — a gratuitous External write is a card.updated that relaunches every label-matching subscription), and after a pass a projection row writes nothing. sync_every: 0 is now a real choice rather than a degradation: the fast path runs with no net under it.
Everything else follows the shipped doctrine:
- Every native write is a CAS; every GitHub write is idempotent by construction (setting a single-select to the value it already has is a no-op we skip anyway, per rule 9.1).
- The pass IS the convergence, not a hope about delivery. It recomputes the truth from the board and the cards on every run, so a missed transition costs a delay of at most one interval, never a permanent divergence. That is why it has a named owner (§10) rather than a sentence in a doc.
- A machine-caused move does NOT reach the roadmap (superseded by the #798 addendum below; the original text exempted the projection from the
tracker.IsMachineReasondecline and let a watchdog park be reflected like any other move). The roadmap follows people and run verdicts; iterion's own bookkeeping — a watchdog park, a column rename, a card given back after a launch that never happened — is left alone and counted (reflect_machine).
Addendum (issue #798) — machine provenance is persisted on the card, and never projected
The pilot on SocialGouv/203 moved 36 Planned tickets to ready; the cloud board dispatcher claimed every one, failed "card has no bot", parked them blocked, and the reflect pushed Blocked onto 30 roadmap tickets as if a human had moved them. Two decisions follow:
- The reflect judges the CARD's provenance, not the event. The store now stamps
Issue.StateReasonat every transition on both twins — the same value the state event'sreasoncarries (native.StateProvenance, one derivation), so the two cannot disagree — andIssue.StateByMachine()is the enumeratedtracker.IsMachineReasonover it.reflectNativeState, the ONE reflect both callers share, refuses such a card (counted, not warned, likereflect_no_column);projectionOwedmaterializes no row for a machine-caused event. The pass, which sees no event, needed the marker on the card; the fast path reads the same marker so a row queued before the card's provenance was known retires through the same answer. - What stays reflected is what the ADR was written for: the dispatcher's own fenced moves carry no machine reason —
in_progressat launch,doneafter a finished run,blockedafter a failed one — and a person's move never does. The Context's motivating case ("a bot moves a native card todone; the board still shows Planned") still holds.
The cause itself is closed upstream of the reflect: the dispatcher's candidate query requires a bot (ListDispatchable), its tick checks the launch preconditions before the claim, and a card that still cannot be launched after the claim is returned to its column under the machine unlaunchable provenance — never parked blocked (docs/dispatcher.md, Claim selection on the cloud board).
11. Permissions, stated up front
- GitHub App:
organization_projects: write— an organization-level permission, so an existing installation does not acquire it silently; the org owner must approve the new grant. It is added to the App manifest as an opt-in profile (ProjectsInstallationPermissions), never folded intoRuntimeInstallationPermissions: a token that can rewrite an org's roadmap is a broader privilege than one that can push a branch, and thesecurity_readprecedent (a separate profile, a separate purpose) is the pattern to imitate. - PAT: classic PATs need the
projectscope (read:projectsuffices for a read-only binding); fine-grained PATs need organization permission Projects: Read and write. - A binding whose credential lacks the grant fails at bind time with the missing permission named — not hours later, at the first status write.
Consequences
pkg/forgegrows one optional capability (BoardClient, type-asserted likeIssueClient/PermissionClient/RepoCreator) and one team-scoped store (BoardBindingStore, memory + Mongo twins under one conformance suite). No existing interface changes.pkg/forge/githubgrows a GraphQL transport shared by the PAT client and the App installation client. A response carryingerrors[]is an error, including whendatais partially populated — GitHub answers200with a populateddataand aNOT_FOUNDerror for a missing project, and treating that as a nil result is exactly the silent-fallback failure mode the repo forbids.- The dispatcher's GitHub tracker gains a board mode: candidates are the items whose
Statusis Planned, andUpdateStatewrites the field. The label claim stays the lease (Projects v2 has nothing to fence with), soClaimLeaserremains unimplemented for it, as today. - Any provider that later grows a project board (GitLab boards, Forgejo projects) implements
BoardClientand inherits the import, the reflect and the dispatcher mode with no engine change. - The engine still knows no specific bot and no specific board: the binding is data, the field names are configuration, and the five Status names are the only vocabulary written down — in one map, next to the states it maps to.
Alternatives rejected wholesale
Mirror the project INTO the native board as a second board. Two boards to keep consistent, two claim domains, and the dispatcher would have to pick one. The native board stays the single operational surface; the project is a view onto it, joined on one field.
Use the GitHub board directly as a Tracker, dropping native. The claim lease (ADR-096), the fencing epoch, the label consume-atomicity and the board-events spine all live on the native store. A Projects v2 item has no CAS primitive to rebuild them on, so this would trade every concurrency guarantee for one less hop.
Poll the project instead of reacting to native events. Polling alone makes a bot's state change visible on the roadmap only at the next tick, which is the lag the epic exists to remove. The reflect is the fast path; the poll stays as its reconciliation net, per the doctrine that a lossy path always needs one.
Shell out to gh project. It is what the current GitHub tracker does for issues, and it is why that adapter cannot run in a cloud pod with a per-connection App token: gh authenticates itself, from its own config. The board capability rides forge.Connection credentials like every other outbound write in pkg/forge.
