Skip to content

0012 — Lot.custodian is computed from the transfer chain

Status

Accepted.

Context

Lot.custodian is a required field on a record in a store with no UPDATE path. The schema is candid about what it is — "derived convenience field, authoritative history is CustodyTransfer" — but the kernel was serving the value the client wrote at creation.

That value is correct until the first CustodyTransfer and silently wrong forever after. A mutable derived field in an append-only store is not a gap in the feature set; it is a live correctness bug, and it is the worst kind, because the wrong answer is well-formed and confident.

Options considered

A projection table maintained on ingest. A lot_custody table updated as transfers arrive. Rejected: it reintroduces mutable state, needs a write path the application role deliberately does not have, and gets the answer wrong whenever records arrive out of order — which invariant 5 (offline default) makes routine rather than exceptional.

A materialised view. Rejected for the refresh question. Either it refreshes on write, which is the projection above with extra steps, or it refreshes on a timer, in which case the kernel serves a custody answer that is knowably stale and cannot say by how much.

Compute on read. Chosen.

Decision

The custody chain is walked at read time. ReadService replaces the stored custodian on every lot it serves with the one the transfers imply, and attaches a custody object recording how it got there:

Field Meaning
custodian who holds it now
asserted what the lot record claimed at creation
as_of when the current holder took it
transfers how many transfers were walked
broken a transfer moved the lot from a party who was not holding it

This is the same reasoning as decision 0002. A derived answer is always right; a stored one is right until the next record arrives.

Superseded and retracted transfers are excluded from the walk: a corrected transfer must not move the lot twice, and a retracted one never happened.

Consequences

Nothing is lost. asserted keeps the original claim, and the raw record is still in the log. The default read is correct and the audit trail survives.

/records/{id}/chain deliberately does not derive custody. That view answers what was claimed, and when. Overwriting every historical version with the current holder would erase the thing the caller came for.

One query per page, not per lot. custodyTransfersFor takes the whole page's lot ids at once.

Consent is decided on the asserted custodian, not the derived one. The guard runs before the walk. Whether custody transfer should also transfer read entitlement is a consent question and belongs to D3, not here.

A broken chain is surfaced, never resolved. Two parties transferring the same lot at once presents as broken: true, the same as a transfer from a party who was not holding it. The kernel does not pick a winner — same principle as the supersession fork in decision 0002.

Rejected: flagging a broken chain at ingest

Attaching custody_chain_broken to the offending transfer would name the record responsible, which is attractive. It was rejected because quality_flags are written once and never updated, so the flag would depend on the order records happened to sync — the same three records arriving in a different order would produce different flags on the same log. An order-dependent immutable flag is a worse artefact than no flag. custody.broken is computed from the whole chain every time and is always correct.