// What a claim's lease MEANS, and the bounds a conforming writer must stay inside. // // A leaf module for the same reason `order.ts` is one: `store.ts` needs these rules too, and it sits // below `materializer.ts`. Both have to evaluate them identically or two operators disagree about // who holds a request — so the rules are pure functions of a record version plus, for liveness, the // two observer-local quantities the fold already depends on (`asOf`, and when this observer first // saw the version). // // The model, in one line: // // > A claim's lease is a DURATION its writer declares, bounded by the protocol, measured by each // > observer on its own clock. // // Three facts motivate every rule below. First, a claim record cannot be deleted (`RepoWriter` has // no delete) and its rkey is a pure function of the request and the claim's generation, so a version // written with an absurd `expiresAt` — one NTP step, one VM resume — is permanent for that claim // unless something bounds it. Second, `createdAt` is frozen across renewals on purpose (the store // adopts a rewrite only when every other field is byte-identical), so `expiresAt − createdAt` grows // with a healthy claim's age and a tight bound against it would kill every long turn. Third, // comparing a writer's `expiresAt` against a reader's clock makes the lease that is actually honoured // `leaseMs − skew`, which is the one place in the fold where two materializers can disagree over // something that is not ingestion lag. // // So: `renewedAt` gives every VERSION a writer timestamp of its own, the declared lease is // `expiresAt − renewedAt` (both endpoints from the same clock, so the writer's offset cancels), and // liveness counts that duration from first observation rather than comparing two clocks. A version // that declares more than the protocol allows is not "long" — it is out of contract, and every // observer refuses it identically. import type { ClaimRecord } from './generated/records.js' import { beforeInstant, instant } from './order.js' /** * The longest lease a conforming claim version may declare between its own `renewedAt` and its * expiry — a WIRE-CONTRACT constant: every implementation must use this number, or two operators * disagree about which versions exist (see `packages/lexicons/README.md`). * * One hour: six times the default lease, and a lease is renewed rather than sized to the turn, so * nothing legitimate comes near it. `run.claims.leaseMs` is refused above it at parse time — an * operator must not be able to configure a lease the fold would then ignore. */ export const MAX_CLAIM_LEASE_MS = 60 * 60 * 1000 /** * The longest span ANY claim version may declare between its own `createdAt` and its expiry. Also a * wire-contract constant. * * Generous on purpose, and NOT comparable to `MAX_CLAIM_LEASE_MS`: the span it bounds is measured * from a `createdAt` that renewal freezes, so it is `age + lease` for a perfectly healthy claim. * Twenty-four hours is "no plausible single turn exceeds it (the default turn timeout is 60 * minutes), and a claim damaged by a clock jump costs a day rather than a decade". * * It applies to every version, including one carrying `renewedAt`, and that is not redundant with * the tight bound: `renewedAt` is self-reported, so a writer whose clock jumps forward and is then * corrected can declare a lease of exactly `leaseMs` sitting a week in the future. `createdAt` is * the only thing on the record that corroborates it. Both bounds together say: this version runs * for at most an hour, and it runs somewhere inside the claim's first day. * * The price is that a claim's TOTAL LIFE is bounded too — after a day, no conforming version of it * exists, whatever its writer does. That is a real limit rather than a technicality (a request * blocked on a human answer sits open far longer than a day), so the writer's answer is not to * stretch the record but to start a new claim beside it: `claimRkey` counts generations, and * `ClaimManager` retires a claim at its horizon and re-claims under the next one. Every generation * is a fresh, honest claim with a fresh place in the tie-break. */ export const MAX_CLAIM_HORIZON_MS = 24 * 60 * 60 * 1000 /** * The lease this version DECLARES, in milliseconds: `expiresAt − (renewedAt ?? createdAt)`. * * `NaN` when either endpoint will not parse — a value can satisfy the lexicon's `format: datetime` * and still defeat `Date.parse`, and such a version must still be judged deterministically. * * Absent `renewedAt` means "this version was issued when the claim was created", which is true of * every claim written before the field existed and of every un-renewed claim since. */ export const claimDeclaredLeaseMs = (claim: ClaimRecord): number => instant(claim.expiresAt) - instant(claim.renewedAt ?? claim.createdAt) /** `expiresAt − createdAt`, in milliseconds; `NaN` when either field will not parse. */ export const claimHorizonMs = (claim: ClaimRecord): number => instant(claim.expiresAt) - instant(claim.createdAt) /** * Could a conforming writer have produced this version? False is a refusal, not an expiry: the fold * ignores such a version outright (and says so in `ignored`) and the store will not select it. * * TWO bounds, and both apply. A version carrying `renewedAt` declares a lease whose endpoints came * from one clock at one moment, so `expiresAt − renewedAt` means exactly "how long this lease runs" * and is bounded tightly. But `renewedAt` is the writer's own word: a clock that jumps forward, * writes a renewal and is then corrected leaves a version declaring a perfectly legal one-lease * duration, sitting days in everybody's future — in contract by the tight bound alone, and (being * the greatest `expiresAt` on the record) selected over every honest renewal that follows it until * wall time catches up. `createdAt` is the only field that corroborates `renewedAt`, so the horizon * bounds every version, whether or not it carries one. * * Both operators evaluate the same predicate over the same bytes, so the clamp costs nothing in * convergence — and being observer-side is the point: it protects a reader from a writer that is * skewed OR lying, which a writer-side check cannot do. */ export function claimWithinContract(claim: ClaimRecord): boolean { return claimContractViolation(claim) === undefined } /** Why a version is out of contract, for the `ignored` diagnostic. Undefined when it conforms. */ export function claimContractViolation(claim: ClaimRecord): string | undefined { if (claim.renewedAt !== undefined) { const lease = claimDeclaredLeaseMs(claim) if (!Number.isFinite(lease)) { return 'claim declares a lease that cannot be measured (unparseable expiresAt or renewedAt)' } if (lease < 0 || lease > MAX_CLAIM_LEASE_MS) { return `claim declares a lease of ${lease}ms, outside the protocol bound of ${MAX_CLAIM_LEASE_MS}ms` } } const horizon = claimHorizonMs(claim) if (!Number.isFinite(horizon)) { return 'claim expires at a time that cannot be measured (unparseable expiresAt or createdAt)' } if (horizon < 0 || horizon > MAX_CLAIM_HORIZON_MS) { return `claim expires ${horizon}ms after it was created, outside the protocol horizon of ${MAX_CLAIM_HORIZON_MS}ms` } return undefined } /** * The last instant any conforming version of a claim created at `createdAt` may expire, in epoch * milliseconds; `NaN` when `createdAt` will not parse. */ const claimHorizonEnd = (createdAt: string): number => instant(createdAt) + MAX_CLAIM_HORIZON_MS /** * The jitter floor of `claimRetirementReason`'s third measure (see `claimRetirementReachMs`) — what * stands in for the renewal machinery's own reach when a configuration renews as late as its lease. * * A writer never declares an expiry beyond `now + leaseMs` — `claimRenewalExpiry` clamps to exactly * that — so any margin at all is only for the jitter that is not a jump: a clock slewed by a few * milliseconds between the write and the next pump, or a `leaseMs` lowered by one notch between runs. * A minute is far more than either needs; never more than the lease itself, so a short configured * lease keeps a proportionate margin rather than a minute-wide one. * * WRITER-SIDE ONLY, like the function that uses it: no observer ever measures a stranger's record * against its own clock. */ export const CLAIM_CLOCK_SLACK_MS = 60_000 /** * What a writer needs to know about its own timing to judge its own records. `ClaimTiming` (the * daemon's `run.claims` block) satisfies it structurally; nothing here reads a stranger's record * against these numbers. */ export interface ClaimWriterTiming { /** How long a lease this writer declares runs. */ leaseMs: number /** How often it renews a held claim — which is what decides how far a jump can be repaired. */ renewIntervalMs: number } /** * How far ahead of `now` one of this writer's OWN records may expire before the writer concludes the * clock that wrote it has since been corrected (`claimRetirementReason`, case 3). * * The number that makes the bound exact is the reach of the renewal machinery, not a flat margin. * A forward jump of Δ caught by the RENEWAL branch leaves a record expiring `Δ + leaseMs` from now, * and that record repairs ITSELF on the next renewal — which `ClaimManager`'s own trigger * (`remaining <= leaseMs − renewIntervalMs`) puts `Δ + renewIntervalMs` after the correction, while * observers stop honouring the version one declared lease after it arrived. So the record is a * problem exactly when `Δ + renewIntervalMs > leaseMs`, i.e. when `Δ > leaseMs − renewIntervalMs` * (400 s at the defaults). * * Below that boundary the next renewal moves the lease on before anybody's honouring window closes, * and retiring instead would cost a generation and — since the retired record keeps the earlier * `createdAt` and so wins its own writer's tie-break until it lapses — up to a full lease of this * daemon's own dispatch, in exchange for a divergence no peer could ever have seen. A host that steps * its clock back by a couple of minutes now and then would pay that every time. * * Hence `leaseMs + max(jitter, leaseMs − renewIntervalMs)`. With `renewIntervalMs ≤ leaseMs / 3` * enforced in config the renewal term dominates at any ordinary setting; `CLAIM_CLOCK_SLACK_MS` * (capped by the lease) is the floor under it. */ export function claimRetirementReachMs(timing: ClaimWriterTiming): number { const jitter = Math.min(CLAIM_CLOCK_SLACK_MS, timing.leaseMs) const renewalReach = Number.isFinite(timing.renewIntervalMs) ? timing.leaseMs - timing.renewIntervalMs : 0 return timing.leaseMs + Math.max(jitter, renewalReach) } /** * The `expiresAt` a writer renewing at `nowMs` should declare for a claim created at `createdAt`: * its configured lease, clamped to what is left of the claim's horizon. `undefined` once the horizon * has passed — the claim's life is over, no version of it can be in contract again, and its writer's * business there is finished (`ClaimManager` retires the row and re-claims under the next * generation rather than writing a version every observer would refuse). * * Clamping rather than refusing near the boundary keeps the last lease honest: it expires exactly at * the horizon, which is where every observer would have stopped honouring it anyway. */ export function claimRenewalExpiry( createdAt: string, nowMs: number, leaseMs: number, ): number | undefined { const end = claimHorizonEnd(createdAt) if (!Number.isFinite(end) || !Number.isFinite(nowMs)) return undefined const expiry = Math.min(nowMs + leaseMs, end) return expiry > nowMs ? expiry : undefined } /** * Why the WRITER of this claim can no longer hold it on a clock reading `nowMs` — undefined while it * still can, whether as it stands or after the repairing rewrite `claimContractViolation` describes. * * **Writer-side only, and deliberately so.** This is the one clock comparison the protocol allows: * a daemon holding its own record against its own clock — the same machine's word measured against * itself, which is not the "my clock versus yours" that `claimLive` exists to eliminate. The fold * must never ask this question; an observer that did would be judging a stranger's clock by its own. * * Three ways a claim ends up beyond its writer's reach, all of them a clock that moved: * * 1. **Its horizon has passed.** `createdAt` is frozen, so no version of it can be in contract * again — renewal cannot fix it and neither can a repair. * 2. **It was created further into our future than one lease.** A renewal we write now would expire * at `min(now + leaseMs, createdAt + MAX_CLAIM_HORIZON_MS)`, which is BEFORE its own `createdAt` * — out of contract, refused by every store, and rewritten again on the next pump. * 3. **It is in contract, and expires further ahead than this writer's own renewals can reach.** * This is what a forward jump that is later corrected leaves behind: every field was stamped by * the fast clock, so `expiresAt − renewedAt` is a legal lease and `expiresAt − createdAt` a * legal horizon. Nothing refuses it, which also means the store will not adopt a shortening * rewrite of it (D3), so its own writer cannot bring it back. Left alone the row is not lapsed * (the lease is days out), not due for renewal, and not named by any other winner — it would sit * `held` forever, gating dispatch behind a claim its writer can neither renew nor retract. * * The bound is `claimRetirementReachMs` — this writer's own lease and renewal interval, never * `MAX_CLAIM_LEASE_MS`. The protocol ceiling is six times the default lease, and everything * between the two is a window in which the row stays `held`, gating dispatch, while every * observer has already stopped honouring the record. But everything BELOW the renewal reach is a * jump the next renewal repairs on its own, invisibly to every peer, so retiring there costs a * generation and a stretch of this daemon's own dispatch for nothing. The reach is exactly the * line between the two. * * In all three the only exit is retirement: stop treating the record as the claim, and claim the * request again in a new record at the next generation (`ClaimManager`, ADR D7). */ export function claimRetirementReason( claim: ClaimRecord, nowMs: number, timing: ClaimWriterTiming, ): string | undefined { if (!Number.isFinite(nowMs)) return undefined const created = instant(claim.createdAt) if (!Number.isFinite(created)) { return `its createdAt (${claim.createdAt}) cannot be read as an instant, so no renewal of it could be judged in contract` } if (claimRenewalExpiry(claim.createdAt, nowMs, timing.leaseMs) === undefined) { return `it has reached the protocol horizon (created ${claim.createdAt}), and no renewal of a claim this old can be in contract` } if (created - nowMs > timing.leaseMs) { return `it was created ${created - nowMs}ms into this daemon's own future, so no lease this daemon could write now would reach its own createdAt` } const expires = instant(claim.expiresAt) const reach = claimRetirementReachMs(timing) if (Number.isFinite(expires) && expires - nowMs > reach && claimWithinContract(claim)) { return ( `it expires ${expires - nowMs}ms from now, further ahead than the longest lease this daemon ` + `could write on the clock it has and still repair by renewal (${reach}ms, from a ` + `${timing.leaseMs}ms lease renewed every ${timing.renewIntervalMs}ms) — the clock that wrote ` + 'it has since been corrected, and a version that is still in contract cannot be shortened' ) } return undefined } /** * Is this claim version still live for THIS observer? * * Two rules, and which one applies is decided by the record, not by the observer, so two operators * that have ingested the same versions apply the same one: * * - **A version carrying `renewedAt`, seen by an observer that stamped when it first saw it**: * live until `firstSeenAt + declared lease`. The declared lease is writer-local (both endpoints, * one clock, one instant), `firstSeenAt` is observer-local, and nothing compares one clock to * the other — so a writer ten minutes fast no longer shortens everybody else's lease, and a * writer that jumps forward mid-lease no longer gets an unretractable one. What is left is * ingestion lag: a claim is honoured from when it ARRIVED, so two operators disagree about who * holds a request for no longer than the gap between their syncs. * - **Anything else** — a legacy version with no `renewedAt`, or a version already in a store that * predates `firstSeenAt` — falls back to comparing `expiresAt` against `asOf`, which is what the * fold has always done. Strictly no worse than before, and it heals on the next poll of that * repo, which re-stamps `firstSeenAt`. * * Instant-based throughout, never lexical: one instant has several legal spellings, so `…:30Z` from * one operator and an `asOf` of `…:30.500Z` on another must still agree (see `order.ts`). */ export function claimLive( claim: ClaimRecord, asOf: string, firstSeenAt: string | undefined, ): boolean { if (claim.renewedAt !== undefined && firstSeenAt !== undefined) { const lease = claimDeclaredLeaseMs(claim) const seen = instant(firstSeenAt) const now = instant(asOf) if (Number.isFinite(lease) && Number.isFinite(seen) && Number.isFinite(now)) { return now < seen + lease } } return !beforeInstant(claim.expiresAt, asOf) } /** * The moment this observer stops honouring the claim, as an ISO instant — the same deadline * `claimLive` tests, made showable. Undefined when it cannot be computed, which is the caller's cue * to fall back to the record's own `expiresAt`. */ export function claimDeadline(claim: ClaimRecord, firstSeenAt: string | undefined): string | undefined { if (claim.renewedAt === undefined || firstSeenAt === undefined) return undefined const lease = claimDeclaredLeaseMs(claim) const seen = instant(firstSeenAt) if (!Number.isFinite(lease) || !Number.isFinite(seen)) return undefined return new Date(seen + lease).toISOString() }