Skip to content

0021 — A forked record is counted in nothing

Status

Accepted.

Context

Spec §8 rule 4 allows two people to correct the same record. The kernel surfaces that as superseded_by with more than one entry and refuses to pick a winner, which is right: two officers weighing the same delivery at 1,380kg and 1,416kg have produced a dispute, and a record layer that silently resolves it has destroyed the only evidence that there was one.

Every derived view, however, sums the tip of each chain, and a fork has two tips. deliveryTallies, settlementGroups, custodyTransfersFor and declaredLossesFor all filtered on not superseded. Under a fork A→{B,C}, A is excluded because it has superseders and both B and C survive the filter. The disputed delivery above was contributing 2,796kg to fulfilment — not one weight or the other, but their sum.

That is worse than any answer we might have chosen deliberately. A number inflated by a dispute looks exactly like a number produced by a larger delivery, and nothing in the payload said otherwise.

Decision

Count neither branch, and mark the derivation incomplete.

A record is forked when it descends, through any number of supersession links, from a record that has more than one superseder in the same dataset. Not just the two records that opened the fork: correcting one branch does not resolve the fork it hangs off, so every record below an unresolved fork is unusable for arithmetic. The predicate is a single recursive walk, written once in record.repository.ts as FORKED alongside SUPERSEDED and RETRACTED, so no future query can forget it.

Forked records are counted and reported, never summed:

View Excluded from Reported as
Fulfilment delivered_kg, deliveries, confirmed, unconvertible forked, incomplete
SettlementSummary referenced_minor, references forked, incomplete
MassBalance the leg sequence and the declared losses incomplete
Custody the transfer walk forked

incomplete is the flag that already exists on MassBalance and Fulfilment, meaning a figure could not be read, so the arithmetic is partial. A fork is that condition arriving by a different route, and reusing the flag means every consumer that already handles an unweighable transfer handles a disputed one without changing.

Alternatives rejected

Count the lower of the two, flagged. This has the kernel pick a winner. It has no basis to. "Lower" is not conservatism — it is a guess that happens to favour the buyer, and in the output it is indistinguishable from a genuine 1,380kg delivery. The flag would be the only thing separating a measured fact from a coin toss, and flags get dropped on the way to a screen.

Refuse to derive until the fork is resolved. One disputed record then poisons a whole page of otherwise sound arithmetic. That is rejecting rather than flagging, which is P6 in spirit if not in letter, and it gives the party who wants a number an incentive to make the dispute disappear.

Consequences

A fork makes a total go down, not up, and the reader is told why. That is the same direction of error as an unconvertible delivery: delivered_kg is a floor, and any percentage taken from it understates. Understating is the safe direction for a lender-facing figure.

Custody.custodian under a fork is where the lot got to before the dispute, not necessarily where it is. Custody.forked says so. It is deliberately not folded into broken, which means something different — a chain that does not link up.

The recursive walk runs per candidate row. It is bounded by chain depth, which decision 0022 caps at SUPERSESSION_MAX_DEPTH, and forks are rare, so in practice it terminates at the first link for almost every record. If it ever shows up in a profile the fix is a materialised fork set, not a cheaper predicate.

Nothing about this stores anything. A fork is resolved by retracting one of the two competing corrections — the retracted branch stops counting towards the fork, and everything below the surviving one becomes countable again on the next read, with no backfill. That is the only resolution path, and it is deliberate: the alternative is a merge, and a merge is an edit.