Skip to content

0022 — Lineage: staleness, cycles, and who may correct

Status

Accepted.

Context

Spec §8 gives five rules for how records supersede one another. Three of them were only half built: staleness of dependent inferences was not computed at all, cycles were assumed impossible rather than refused, and correction rights stopped at the original claimant.

Decision

Staleness is derived, never stored

§8 rule 5 says superseding an observation does not invalidate the inferences computed from it — it marks them stale for the owning model to re-run.

There is no way to mark an existing row in an append-only log. The only thing we could write is a second record asserting that the first is stale, which is a fact about our bookkeeping rather than about the world, and it would be wrong the moment the input moved again.

So an inference is stale iff any record it depends on has been superseded or retracted, computed at read time and attached to the view as RecordView.staleness. Same shape as the balance and settlement summaries.

Three details that are not incidental:

The reason is not one thing. A superseded input means a newer value exists and the model can recompute. A retracted input means the input is gone and recomputation may be impossible. stale_reason reports input_superseded, input_retracted, or both, because the operational response differs: one is a job to re-run, the other is a conversation.

A client-supplied stale is discarded on ingest, alongside recorded_at and superseded_by. A client able to assert stale: false can assert its way out of the re-run the superseded input exists to force.

The dependency lookup is generic over any field naming a record. DEPENDENCY_FIELDS is ['inputs', 'validated_by']. validated_by is there now rather than when work order G lands, because a retracted validator no longer settles anything and the verdict rests on it exactly as the prediction rests on its inputs. G adds nothing here.

One query per page, not per inference. dependencyStatus takes every dependency id across the whole page and returns one map. It looks in both facts.record and inference.record: an inference built on another inference is a real shape, and looking only at the fact log would report those dependencies as unresolved — which reads as we could not check and would hide exactly the compounding error inference_depth exists to expose.

Cycles are refused at ingest, and depth is bounded

§8 rule 3 permits chains and rejects cycles. A cycle would make a read that resolves to the tip loop forever.

Worth recording what the tests found: a cycle cannot be built today. supersedes must name a record already in the log, and a fresh record's id is by definition not in the log, so every edge points from a new node to an older one and the graph is always a DAG. An A→B→C→A attempt is currently caught as an id_conflict in appendIfAbsent, not as a cycle. The one-link case is caught earlier still, by the envelope schema's own a record cannot supersede itself refinement, and surfaces as malformed_record.

The explicit check was added anyway. That argument depends on two separate methods continuing to hold, in two different files, and an emergent property nobody tests is a property that decays. checkChainShape walks the existing ancestry before accepting the link and makes the guarantee local.

The same walk bounds depth. A 10,000-link chain is a denial of service against every default read, not just reads of that record. SUPERSESSION_MAX_DEPTH defaults to 64 and is configuration rather than a constant, for two reasons: an instance that meets a legitimately long chain can raise it without a deploy, and the ingest-side check and the read-side walks cannot drift apart.

Approaching the bound flags rather than rejects, per P6: supersession_chain_deep appears from 75% of the limit. Only crossing it is refused, and the message says what the limit is.

A correction right can be delegated

§8 rule 1 says only the original asserted_by, or a party holding an explicit correction right over them, may supersede. Only the first half existed.

The second half is a Delegation whose scope covers the record type — a cooperative correcting a member's delivery when the member is unreachable, which is the common real case. It resolves exactly as ingest already resolves delegation for on_behalf_of: in scope, in date, not revoked.

The delegation must be named on the correction. The kernel does not search the log for some grant that happens to authorise the writer. If it did, a correction's authority would depend on what else had been written since, and nobody could audit that.

Weaker authority stays visible. A correction made under organisational_bylaw carries corrected_under_organisational_bylaw as well as corrected_under_delegation, so a lender walking the chain can see which link rests on a bylaw and which on the farmer's own word without fetching the delegation.

Consequences

checkRetraction was not extended, and that is a known inconsistency. Its own comment says a retraction has the same authority requirement as a correction, so correction rights reaching further than retraction rights makes them diverge. It was left because the delegation scope for a Retraction is genuinely ambiguous — scope is per record type (open decision D7), and for a retraction the record's type is retraction while its target is a delivery. For supersession those two are identical, so the question never arose. Flagged rather than guessed; it needs D7 settled.

Reading the chain of a delegated correction is currently refused by the consent stub for any real two-party delivery. subjectsOf('delivery') yields from_party, to_party, lot and fulfils, and decision 0009's stub allows a self-read only when the requester is the only subject. That is a property of the stub, not of lineage, but it means the bylaw label is only reachable today on single-party records. It resolves when consent does.

staleness is attached to the view and the stored stale field in an inference's body is left untouched, per the work order. That means a payload can read stale: false while view.staleness.stale is true. Lot.custodian is overwritten in place for the same class of reason — a schema field the kernel owns and the stored value goes stale — and the two should probably agree. Open.