Grading — how Attest decides

Who this is for: anyone who wants to know exactly why a receipt says VERIFIED, PARTIAL, NOT FOUND, UNASKED, OVER SCOPE or UNVERIFIABLE, and anyone changing src/core. What you’ll know after: every grade, every rule in policy/default.json, every feature, the windows, the receipt state machine, and how attest replay proves a receipt.

Status: v1 of the grading reference, on the v1.19 contract, as Attest 0.1.0 grades (Sprint 1, with the Sprint 2 wave C, D and E rules, the Sprint 2 close, the first real captures, their re-review, the live smoke, the Sprint 3 clock-repair fix, the Sprint 3 close, the Sprint 4 popup and the Sprint 5 model score). The contract was revised after three adversarial reviews (v0: 9 blockers; v1: 7; v1.1: 3; all fixed) and the clarifications raised while implementing waves A–C (schemas, normalisation, policy, golden scenarios, features, redaction, candidates, grade, state, attribution, scope, batch). It was written before the code (sprint 1: “write scenarios first”). Task 1.11 added the contents list, the state-machine diagram (§8), the replay procedure (§9) and the worked examples (§16), and changed no rule, condition, definition or number. The wave D integration then aligned four points with the code and logged them in Decisions.md: UNASKED finality is measured from the same anchor as attribution (§7), a superseded receipt is never regraded (regrade_superseded, §8), replay compares every computed receipt field and each observation’s contentHash (§9), and the §3 and §13 gradeBatch signatures match batch.ts.

v1.6 is the Sprint 1 review close (Decisions.md, Sprint 1 close entries). Rules it changed: text is read as Unicode 15.0 reads it, on every engine, and a lone surrogate reads as U+FFFD (§11 Unicode baseline, §10, §14 item 8); every grading entry refuses a policy object loadPolicy did not return, and the loader refuses a rule that concludes a grade without naming its evidence, and a thresholds.partial of 0 bp (§3, §6, §5.3); a score condition (bestScoreGte, bestScoreLt) is not applicable to a claim that is never scored (§6.1); grade refuses a candidate §4.1 could not have returned (foreign_candidate, §3); a candidate and an orphan come only from a source the policy lists for the observation’s type, by one predicate (fromRelevantSource) in gradeBatch and replay: a row from another source neither verifies a claim nor accuses an agent (§4.1, §3, §9); every pre-assignment carries the batches it is in force in, derived by one rule (preAssignments, batch-fixed.ts) for gradeBatch and replay, and an override releases or takes an observation only in batches evaluated strictly after its at (§3, §4.3, §9); a claim with no currency is counted in spendCurrency by the cumulative ceiling and by priorSpendCents (§6.1, §6.3); a claim never counts its own id in its prior spend (§6.3); gradeBatch adds the graded claims activity.claims omits to the spend and adjacency history (§3); project refuses a row whose allowlisted value fails its field schema or is held by a getter, a setter or a prototype, the ledger refuses a row project could not have returned, keeps the earliest observedAt of a fact seen twice (v1.7 refuses the earlier sighting instead, below) and refuses a claim or observation that differs from the stored one under the same rawHash or contentHash in anything but observedAt (id_conflict), and counterparty, merchant and subject hint are one line (§10, §13); a health reason is a reason code and an adapter, observer or extractor id is a short token, never text (§13). For inputs that met v1.5, grades change only in six cases: a currency-less spend claim whose earlier spend breaks the ceiling (now OVER_SCOPE); a spend claim whose id an earlier-claimedAt copy in the history also holds (its prior spend no longer counts that copy); a row from a source the policy does not list for its type (never a candidate, never an orphan); a claim that is never scored, under a policy that orders a rule naming bestScoreLt before the rule that catches such a claim (the shipped order is unaffected: no_target, underspecified and no_coverage come first); text holding a code point that Unicode 15.0 does not assign or that a later version reclassified (now read as Chrome 120 reads it, on every engine); and text in which an invisible or nonspacing mark separates two lone surrogate halves (each half now reads as U+FFFD on every engine, never as the character the halves would form). Wording it corrected to match the code: amountAboveCeiling is false, never not applicable, within its guard (§6.1); isFinalizable takes one FinalityInput object (§8, §13); §13 lists the new modules and their errors; §15 now documents every key of the golden file format; §3 states what the background owes a batch (Sprint 3.6, 4.3).

v1.7 is the third Sprint 1 review close (Decisions.md, [1.close-r3] entries). Rules it changed: the ledger never rewrites a stored claim or observation. An earlier sighting of a stored hash is refused (late_sighting) (v1.8: of a stored observation only, below), replacing v1.6’s keep-the-earliest merge. A new row’s observedAt is set where it is written, no earlier than the writer’s now and after the last recorded batch instant, and a batch records its instant with recordBatchInstant before it reads (§3, §10). A claim’s extractor is not compared under its rawHash (§10). Request.conversationId is a vendor id token, and scopes and allowlist entries are one line (§13). Batch instants are strictly increasing: gradeBatch refuses a claim pre-assignment from a grading at or after evaluatedAt (stale_evaluated_at), and claim_fixed names only a pre-assignment that has not ended (§3). A revert carries its instant (revert({at})) and never erases what a batch saw: the receipt keeps the override in reverted, the override stays in force for the batches up to its revertedAt (pre-assignments gain released spans, and until now marks only the end of a reverted override from PENDING), and regrade refuses a receipt computed while a reverted override held the subject (regrade_held) (§3, §4.3, §7, §8, §9). The policy loader refuses an unasked.threshold of 0 bp (§5.3). Grades change for no v1.6 input in a ledger where no override was reverted (no revert caller ships before Sprint 4.3); the ledger and gradeBatch now refuse the out-of-order writes and batches that would have made replay fail. Wording it added: what the background owes a batch (§3), what the fences on src/core catch and what they cannot (§14 items 8 and 9), and the escaping of the file name attest replay echoes (§9).

v1.8 is the fourth Sprint 1 review close (Decisions.md, [1.close-r4] entries). Rules it changed: an override’s at and a revert’s instant are stamped by writeUserEvent, inside one readwrite transaction over receipts and meta, no earlier than 1 ms after the last recorded batch instant, as a new row is, so a clock that went back can no longer put an event in force for a batch that never read it (§3). An observation the ledger stored later than it was sighted keeps the sighting as sightedAt, set only by the ledger and strictly before observedAt (§10, §13). §4.1’s calendar.created first-seen rule, the §7 calendar anchor (attribution windows and UNASKED finality), the representative of a contentHash and the ledger’s late_sighting read the first sighting (sightedAt, else observedAt; order.ts firstSeenAt), while observedAt alone still decides which batches read a row (§3, §4.1, §7, §10). A candidate’s refHash binds sightedAt when present (§3). A re-read of a stored claim is a no-op however early it was sighted (a claim’s sighting grades nothing), so late_sighting is raised for observations only (§3, §10). writeUserEvent also stamps an event at least 1 ms after the receipt’s own last event (notBefore = lastEventAt(receipt)), so a revert or an override whose clock went back below that event is saved instead of refused (invalid_event) (§3). For a row without sightedAt (every row stored as sighted, and every v1.7 row) nothing changes: the same candidates, anchors, hashes and grades. Grades change only where an observation was stored later than it was sighted: a calendar orphan’s attribution and UNASKED finality are measured from its sighting, and an event already on the calendar before a calendar.created claim but written after a batch no longer verifies “created” (§4.1 states the case that still can, with its measured rate). Wording it changed in §14 item 9 (with the [1.close-r5] entries; no rule or grade changed): lint and the grep test hold every global src/core reads to an allowlist of ES2023 built-ins and read RegExp only to build or test, the guard poisons the names on lint’s one restricted list, lint and the grep test refuse the comments that would switch the ES2023-only typecheck off, the guard’s two deliberate allowances are stated, and the example of code that detects the guard is one that lint and the grep test pass.

v1.9 is the Sprint 2 wave C contract (Decisions.md, Sprint 2 entries): the grading side of the observers’ coverage. Rules it changed: a coverage entry may carry changesSince (gcal.event only, set by the ledger from the stored snapshot chain, never after lastSyncAt): since when that Calendar view followed the window’s changes (§3, §13). A calendar.updated or calendar.deleted claim counts a gcal.event entry towards full coverage only if the entry followed changes over the claim’s whole change span [start(p,t) − beforeClaimMin, end(p,t) + graceMin]; any other entry is capped with the new reason coverage.changes_unfollowed (§6.1, §15). A gcal.event row is an orphan only if it is a change a view followed: a covering entry had changesSince before the row’s first sighting and synced at or after it, and no covering entry may have found the row already there (one that began comparing at that first sighting, or one that compared nothing and synced at or after it), so what a first look found already on the calendar never accuses an agent (§3, §7). The observer obligations now say what each source’s coverage asserts: Gmail Sent writes coverage only from the head of the list; a receipt pass covers only its message’s instant (a point, so receipts never make coverage full, while an exact receipt still verifies and an unclaimed one can be UNASKED); Calendar entries carry changesSince, and a Calendar pass’s rows are stored with its coverage, all or none; the background acknowledges every observer message (§3). §10 states the one-observation-per-Calendar-event rule. Grades change only for calendar change claims (never NOT_FOUND without a view that followed changes over the change span) and calendar orphans (none without one); a ledger with no gcal.event rows and no calendar change claims grades as under v1.8. A coverage entry without changesSince hashes as it did before (§3). Goldens 99–103 are new and 86 gained changesSince; files from 100 on are named with three digits (§15).

v1.10 is the Sprint 2 wave D contract (Decisions.md, Sprint 2 entries): what the ledger owes the Calendar orphan rule, and Calendar views looked at too early. Rules it changed: every row a Calendar pass stores as a first sighting (an event its base could not have seen: a first look, a calendar switched on, a day that was hidden, an event that began before the visible range, or any other case) gets a first-look marker, written by the ledger in the same transaction as the row: a gcal.event entry of the same observer and account whose window is the row’s occurredAt (a point) and whose lastSyncAt and changesSince are the row’s first sighting. Orphan rule (b) reads it like any entry, so what a Calendar pass found already there never accuses an agent, even when no window of that pass holds the row; a point never makes coverage full (§3, §6.1, §7). A Calendar pass is stored all or none, in one readwrite transaction that aborts on any refusal, so a refused pass leaves no row and no marker; Gmail rows keep the 2.7 rule (each row on its own, no coverage after any refusal) (§3). A gcal.event entry counts towards full coverage of any calendar.* claim only if its lastSyncAt ≥ end(p,t) + graceMin; any other overlapping entry is capped with coverage.changes_unfollowed, the cap a change claim’s unfollowed entry already had: a view looked at before the claim cannot say the event was never created (§6.1, §15; conditions.ts calendarBound). §3 now also states what the observers and the service worker do since wave C: several entries per pass, Gmail Sent coverage from the head of the list only, receipt points, no Calendar coverage while the grid is still drawing, the two-absence deletion rule and the pass that waits for it, the run of followed changes breaking when the shown calendars change, and the service worker’s refusals of a gmail.sent entry whose windowEnd is not its lastSyncAt and of a gcal.event entry that reaches a day its snapshot hid. Grades change only for a calendar.created claim whose window the entries synced at or after end(p,t) + graceMin do not contain whole while older ones did (never NOT_FOUND now: partial_coverage until a view is synced again; a change claim’s change span already asked this), and, through the entries the ledger now writes, for a row a marker covers (never an orphan) and for a claim whose grant binds no gcal.event account when a marker brings a second account into its coverage (capped coverage.multiple_accounts, as that account’s own windows would be, §6.1); no golden from 01 to 103 changed. Goldens 104–108 are new (§15, §16.15).

v1.11 is the Sprint 2 wave E contract (Decisions.md, Sprint 2 entries), from the Calendar simulator’s findings CAL-SIM-1 to CAL-SIM-7 (5,500 seeded runs of the real handlers, ledger and gradeBatch). Rules it changed: (a) a calendar.updated or calendar.deleted claim is never NOT_FOUND. Calendar is read as snapshots, and a change made between two looks and then moved, changed again or removed, or one that reached the ledger as rows of other types (an event moved between two followed ranges, a create then edit between two passes), leaves nothing of the claimed type to compare (CAL-SIM-1, CAL-SIM-6). Every gcal.event entry over such a claim’s window is capped with the new reason coverage.change_unprovable; a change row a view saw still verifies or is PARTIAL evidence, and a claim with none is partial_coverage, UNVERIFIABLE once it finalises by time. calendar.created keeps NOT_FOUND, with the meaning §6.1 now states. The v1.9 change-span cap goes with it: no coverage level reads changesSince any more, the orphan rule still does (§3, §4.1, §6.1; conditions.ts changeSpan is removed and calendarBound is {syncMs, change}). (b) Coverage is per bound account: when grant.accounts[source] binds two or more accounts, a source is full only when each bound account’s own entries contain the window, and a window that only their union contains is capped with the new reason coverage.account_partial (CAL-SIM-4; every source; an unbound grant keeps coverage.multiple_accounts) (§6.1). (c) No evidence on time alone: a pair none of whose content features (every applicable feature but timeProximity) scores above 0 scores 0, so it is never assigned, never PARTIAL and never VERIFIED (CAL-SIM-5), and the loader refuses timeProximity in identityFeatures (§5.2, §5.3). (d) Clock safety: what the ledger owes when the wall clock jumped forward and back or stepped back for good (CAL-SIM-2, CAL-SIM-3, CAL-SIM-7) (§3). (e) A change Attest noticed long after it happened, its comparison base read more than the attribution window before its first sighting, is stored as a first sighting with its first-look marker, so it never accuses an agent (§3, §7). Grades change for calendar change claims (partial_coverage where v1.10 said NOT_FOUND), for claims under a grant binding two or more accounts to a source whose window one account’s views contain and another’s do not (partial_coverage instead of NOT_FOUND), and for pairs that matched on time alone (never PARTIAL or VERIFIED, and a best candidate’s score that was the time-only mean is now 0). Golden expectations changed in 78 and 85 (a time-only best score is now 0/1), 102 (its cap is coverage.change_unprovable) and 103 (c2 is partial_coverage, no longer NOT_FOUND), with the reason in each file’s notes; the notes of golden 13 restate its c1 × o2 arithmetic. Goldens 109–111 are new (§15, §16.15). The known limits these rules leave are stated where each rule is (§3, §5.3, §6.1, §7). The Sprint 2 [2.7-contract] Decisions entry that kept the stale-view false NOT_FOUND “for now” is superseded by v1.10.

v1.12 is the Sprint 2 close (Decisions.md, [2.close] entries), from the sprint-end reviews. Rules it changed: (a) Calendar changes other people make (SEC-1). A snapshot cannot tell a change the account made from one other people made to the account’s calendar: Google adds an invitation from anyone (its default setting), an organiser moves or cancels an event the user was invited to, a colleague edits an event on a shared or subscribed calendar the grid shows. Until the live smoke (sprint_2 2.8) confirms on real pages which facts say that the account organises an event and owns the calendar it is on, the service worker stores every Calendar row as a first sighting with its first-look marker, a change a followed view saw included, so no gcal.event row is ever an orphan and no Calendar row is UNASKED: a missed UNASKED, never a false one (Hard Rule 7). The rows stay evidence for claims (§4.1). The orphan rule is unchanged in src/core: what changed is what the ledger is given, so gradeBatch and replay grade the same inputs exactly as before (§3 Changes the account did not make, §7). Grades change only through the ledger: a Calendar change that v1.11’s ledger stored without a marker inside a followed view (so it could be UNASKED) now has one. Golden 112 is new (§15, §16.15). Carry-forward to 2.8: read the organiser and owned-calendar signals (read-only, best knowledge) and store as a change only a change to an event the account organises on a calendar it owns. (b) What else a Calendar pass owes (§3): at most 20 calendar.updated rows per event per day, a pass that holds one back recording its windows without changesSince and leaving the observer DEGRADED ledger.event_churn (SEC-11), so an event edited more than 20 times in a day has fewer calendar.updated rows (a change claim one of them would have verified is partial_coverage, never NOT_FOUND, §6.1); the service worker refuses a gcal.event entry shorter than a day less an hour or longer than 42 days and an hour, with an account or without (SEC-8); and a Calendar page that has looked busy through 5 minutes of passes is DEGRADED page.busy instead of OK with no coverage (SEC-10). Neither (a) nor (b) changes the expectation of an existing golden. (c) Gmail coverage (§3): the gmail.sent head window starts one minute after its oldest row’s minute while the list may go on (an enabled Older, or none seen), because a send in that minute older than the oldest row is on the next page, unseen; and a gmail.receipt pass writes no entry for a receipt from a sender outside the registry (the generic parser), nor for a registry receipt dated in the same minute as one, so a stranger’s mail is evidence for a claim, never coverage. Rows carry no parser, so a registry receipt’s entry written by another pass in the same minute still holds a generic row (attacks-that-work.md). Like (a), (c) changes only what the ledger is given, never src/core, so no golden expectation changes either.

v1.13 is the first real captures (Decisions.md, [2.1-real] entries; Gmail and Calendar captured on 2026-10-06). Rules it changed: (a) gmail.sent (§3): a Sent row that holds several messages (its thread, last and last non-draft ids all there, not one id: a conversation with a reply or a draft) gives no row, because the list shows only its newest message’s minute, and the head’s window starts one minute after the newest such row, so it never holds a send the pass did not time; 48 of the 50 rows on the first real Sent page are such rows. A row missing one of those ids is still refused (DEGRADED). (b) gcal.event (§3): an event id the page shows that no chip reads makes the pass DEGRADED event.unreadable instead of leaving the event out while the window vouches for its day; real month view draws its events in a shape whose days the reader cannot place yet, so a month view is DEGRADED until it can. Neither changes src/core, a policy or an existing golden: both change only what the ledger is given.

v1.14 is the re-review of the first real captures (Decisions.md, [2.close-r2] entries; tech re-review TECH-R2). Rules it changed: (a) every claim caps each gmail.sent entry last synced before its grace sync, max(end(p,t) + graceMin, end(p,t) + afterClaimMin) (coverage.list_unsettled, §6.1). A Sent list read before it shows a send made elsewhere (an agent’s reply sent through the API is listed under its conversation’s newest minute once the open tab’s list refreshes, and a conversation row is never a row, v1.13) vouches for a span it has not settled, so only a view synced after the grace sync can make an email claim NOT_FOUND; a send the early view saw still verifies. (b) A view synced too early names its cap (coverage.changes_unfollowed, coverage.list_unsettled) only for a span of the window no later view saw: an earlier view a later one contains adds nothing (§6.1). (c) The ledger merges two gmail.sent intervals only as it merges two gcal.event ones: synced at one instant, or the earlier-synced window inside the later-synced one, so a union never carries a later sync over a span the later pass did not see (§3). (d) gmail.sent (§3): the account’s own mailbox is never a counterparty (any case, a +tag, Gmail’s dots), and a row that names it narrows the head’s window like a conversation row, so a claim naming the account is UNVERIFIABLE, never NOT_FOUND; a list whose shown minutes are not in order, newest first, vouches for no window. Golden scenario 113.

v1.15 is the live smoke (Decisions.md, [2.10] entries; sprint_2 2.8 finding 1). Rules it changed: (a) gmail.sent (§3): a Sent row holds one message, and its minute is a send’s, when its last message is its last non-draft one, Gmail shows no message count beside it, and either its thread id is that id too or the list draws counts at all. Gmail draws no count for one message, and a mail composed as a draft first keeps the draft’s older id as its thread id, so under v1.13 such single sends were read as conversations: no row, and the head’s window started after them (on the real Sent pages of 2026-10-06, 51 of the 158 rows had an older thread id and no count). A count beside a row whose ids are one id contradicts them, and the row is not timed; a list that draws no count anywhere is read by its ids alone, as under v1.13. It does not change src/core, a policy or an existing golden: only what the ledger is given.

v1.16 is the Sprint 3 clock-repair fix (Decisions.md, [3.17]), for the Sprint 2 simulator finding S2-SIM2-1 ([2.7-E]). Rules it changed: each clock repair now keeps its span, from the clock’s now when the ledger found it below its high water to that high water, and every grading batch’s coverage carries the spans the ledger keeps (Coverage.repairs; a grade sees those found by its evaluatedAt, and coverageHash reads them as a set, an empty list as none) (§3). A calendar.created claim whose first-seen bound start(p,t) − beforeClaimMin (§4.1) lies in a span has every gcal.event entry over its window capped with the new reason coverage.clock_repaired (§6.1, §15): the claim may have been stamped by a clock that ran ahead, or its event first seen by a clock set back below it, so a row first seen after the repair can read as older than the claim, fail the first-seen rule, and leave a whole, fresh view with no candidate. Grades change only for such a claim with no row first seen at or after its bound: partial_coverage (UNVERIFIABLE once final) where v1.15 said NOT_FOUND, FINAL (8 of 23,420 simulator receipts, all in histories with clock jumps). conditions.ts calendarBound is {syncMs, change, repaired}. No existing golden expectation or id changed; golden 114 is new (§15, §16.15).

v1.17 is the Sprint 3 close (Decisions.md, [3.close] entries; security review SEC-2). Rule it changed (§7): a session ping and an activity entry of one agent at one instant are one fact. On the paste path a log’s timed entry is both, its minute a session ping and its claim an activity entry, so one entry fired agentSessionActive and activityLogAdjacent together, 8000 bp against the 6000 threshold: a send of the person’s own within two minutes of any pasted action of another type was UNASKED, likely that agent. Now, when activityLogAdjacent fires and every session ping within ±sessionMin of the anchor sits at the instant of an entry that fires it, only the heavier of the two signals fires (activityLogAdjacent on a tie: both weigh 4000 bp). A ping at any other instant, such as the minute of a log line that is no claim, is a fact of its own. Grades change only for an orphan whose agent signals all came from one entry: no UNASKED where v1.16 issued one. No existing golden changed; golden 115 is new (§16.15).

v1.18 is the Sprint 4 popup (Decisions.md, [4.2]). It adds an output and changes no grade, state or reason. A receipt that is not FINAL now says from when it can be: finalFrom (§8), written by grade() in the offset of evaluatedAt, so the popup’s “final from 14:48” is a stored value (the Sprint 4 rule: the UI computes nothing). It is the instant a VERIFIED receipt’s sources must sync at or after (end(p,t) + graceMin); the end of the grace for PARTIAL, NOT_FOUND, an OVER_SCOPE that rests on its candidate, and PENDING (a grading whose sources synced at or after it decides the final grade); the deadline for the coverage-based UNVERIFIABLE (end(p,t) + finalizeUnverifiableHours); and for UNASKED the earlier of the instant its agent’s adapter must sync at or after and its deadline (§7). A FINAL receipt has none. Replay compares it (§9), so a PENDING receipt stored before v1.18, which has none, replays as a finalFrom mismatch until the next batch regrades it; FINAL ones are unchanged. Every golden’s PENDING receipt carries one (a runner check), and five pin it (03, 14, 49, 61).

v1.19 is Sprint 5.0 (Decisions.md, [5.0]): the advisory model’s score (§5.4). A claim’s context may carry the model’s scores of its candidates (modelScores, §3), and the best candidate, chosen and assigned on its floor score, may have that score raised to max(S, (1 − λ)·S + λ·m) when S is already at or above thresholds.partial; λ is features.modelScore (0.15), capped by the loader so that the model alone never verifies a partial match, and modelScore is never a pair feature (the loader refuses it in featuresByType and identityFeatures). With no model score in the context, nothing changes: no existing grade, golden or receipt moves.

Where this file and the code disagree, the code is wrong until this file is changed by a reviewed commit. Every deviation from build_spec §7 is listed in §14 and in Decisions.md.

Contents

  1. The shape of the problem
  2. Grades and states
  3. Inputs
  4. Candidates: the window, retrieval, one-to-one assignment
  5. Features and score: every feature, applicability, the exact score
  6. Rules: every condition, the default rule order, cumulative spend, reasons
  7. UNASKED: attribution signals, always “likely”
  8. State machine, with the diagram
  9. Replay and the replay procedure
  10. Ids and hashes
  11. Normalisation
  12. Policy keys: every number in policy/default.json
  13. Module contracts
  14. Deviations from build_spec §7
  15. Golden scenarios: the file format and the ReasonCode table
  16. Worked examples: the 12 mandatory golden scenarios and the §2 demo, with exact arithmetic, and where each rule fires

1. The shape of the problem

An agent claims it did something (a Claim, read from the agent’s own page). The user’s own Gmail and Calendar tabs show what actually happened (Observations). Attest matches claims to observations and issues one Receipt per claim, plus a receipt for each action nobody claimed that an agent likely did (UNASKED), plus one per permission an agent holds beyond what the user declared.

Everything in src/core is a pure function: no clock, no randomness, no I/O, no DOM, no chrome.*, no model, no Intl, no locale (Hard Rule 4). Time enters only as data (Coverage.evaluatedAt). No grade is ever decided by floating-point arithmetic (§5.3).

2. Grades and states

Grade Means Never means
VERIFIED Independent evidence matches the claim with score ≥ thresholds.verified. “The agent told the truth” in any wider sense. Verified = matched independent evidence.
PARTIAL Some evidence matches (score ≥ thresholds.partial), not enough to verify. A soft fail. It names what matched and what did not.
NOT_FOUND Every relevant observer saw the whole claim window after the grace period, and nothing matched. “Not found yet”. Before grace, or with partial coverage, the grade is never NOT_FOUND (Hard Rule 5), and a calendar change claim never is (§6.1).
UNASKED An action happened that no claim explains, and attribution signals say an agent likely did it. Proof of who did it (Hard Rule 7). Every UNASKED receipt carries attribution: "likely".
OVER_SCOPE The claimed action is outside the user’s Grant (type, counterparty, spend), or the agent holds a permission the user did not declare. That the action was harmful.
UNVERIFIABLE Attest could not see enough to judge: no observer covers the type, the tab was not open over the window, coverage was partial, or the claim is too vague to match. Anything about the agent (Hard Rule 5).

States: PENDING (still checking), FINAL, USER_OVERRIDDEN (§8). While the rule outcome is PENDING, the receipt’s grade field holds UNVERIFIABLE as a placeholder (it is the only grade that claims nothing), and ruleId is pending. The UI shows PENDING as “checking”, never as a grade, and counts (digest, telemetry) exclude PENDING receipts.

3. Inputs

grade(claim | null, candidates, grant, policy, coverage, context) → Receipt          (grade.ts)
gradeBatch(claims, index, grants, policy, coverage, activity, fixed, modelScores) → Promise<{receipt, inputs}[]>   (batch.ts; fixed: {observationId, claimId | null, after, until?, released?}[], null = taken; a pair is in force where after < evaluatedAt ≤ until and evaluatedAt lies in none of its released spans (from, to], §4.3; modelScores: claim id → that claim's model scores, §5.4, Sprint 5.4)

gradeBatch is the only path from the background to receipts: it retrieves candidates, scores every eligible pair, assigns observations (§4.3), computes cumulative spend (§6.3) and attribution (§7), then calls grade per claim, per orphan and per scope subject (§6.1). grade alone is used by tests and replay. gradeBatch grades exactly the PENDING claims it is given, except that a claim with observedAt > evaluatedAt gets no receipt (it is ignored, below); priorSpendCents and attribution read activity.claims, which holds every claim of the user within retention, FINAL and USER_OVERRIDDEN included. gradeBatch also adds every graded claim (first seen by evaluatedAt) whose id activity.claims does not list, so a caller that omits the PENDING claims still gets the cumulative ceiling and adjacency suppression (§7); an id in both counts once (the activity.claims copy), and never toward the claim that holds that id (§6.3). It returns {receipt, inputs: {grant, context, policyHash}}[] (coverage is the batch’s own) so the caller can store receiptInputs for replay, for claim receipts and for the claim-less receipts whose matched rule concludes UNASKED (orphans) or OVER_SCOPE (observed scopes); any other claim-less outcome yields no receipt. The grant in force for an agent is the one with the latest updatedAt ≤ evaluatedAt; none, or two at the same instant, throws BatchError (missing_grant, ambiguous_grant). Orphans are the observations from a source the policy lists for their type (policy.sourcesByType[type], the relevant sources of §6.1 that §4.1 holds candidates to) whose occurredAt lies inside a coverage interval of their own source (with lastSyncAt ≤ evaluatedAt), that no claim was assigned and that none is taken. An observation from a source the policy does not list for its type is neither a candidate (§4.1) nor an orphan: evidence the type’s coverage never reads neither verifies a claim nor accuses an agent (Hard Rule 5). A gcal.event row must also be a change Attest saw happen (v1.9): (a) some gcal.event entry synced by evaluatedAt whose window holds the row’s occurredAt followed changes from strictly before the row’s first sighting through it (changesSince < firstSeenAt ≤ lastSyncAt; firstSeenAt per §4.1), and (b) no such entry may have found the row already there: none began comparing at that first sighting (changesSince equal to it), and none that compared nothing (no changesSince) was synced at or after it. A first look reports every event already on the calendar as a first sighting (a calendar.created row, §10), and none of those is an action anyone took (Hard Rule 7). Rows carry no account, so (b) stands in for “of its own account”: a row that another account’s first look found at that instant is never an orphan, even inside a window the first account was following. Every first sighting a Calendar pass stores carries its first-look marker (below, v1.10), an entry (b) reads, so a row a pass found already there is never an orphan even when no window of that pass holds its start. Since v1.12 every Calendar row the service worker stores is such a first sighting, a change a followed view saw included (Changes the account did not make, below), so no gcal.event row of a ledger the extension wrote is an orphan; rules (a) and (b) stay as they are for the day the account’s own changes can be told. The rule is decided on each contentHash’s representative, its first sighting (§4.1), never on a later re-sighting. gradeBatch and replay decide all of this with one predicate, isOrphanCandidate in batch-orphans.ts. gradeBatch is blind to stored receipt states: for an orphan whose stored UNASKED receipt is FINAL or USER_OVERRIDDEN, the caller keeps the stored row and discards the regrade. fixed is preAssignments(storedReceipts) (batch-fixed.ts, §4.3); gradeBatch fixes the pairs in force at evaluatedAt. It throws BatchError('claim_fixed') for a graded claim named by a pair that has not ended by evaluatedAt (no until, or until ≥ evaluatedAt), in force or not. It throws BatchError('stale_evaluated_at') for a claim pair whose after (a grading of its claim) is at or after evaluatedAt: that is a batch stamped at an instant already graded, in which the earlier batch’s FINAL evidence would be free again. Taken pairs may start later: an override stamped after evaluatedAt but read by the batch. grade sets state and finalAt itself (finality rules live in conditions.ts, §8); state.ts only applies user and lifecycle events to stored receipts.

Coverage.observers and each Activity list hold at most SCHEMA_LIMITS.GRADING_INPUT entries; the ledger readers return the entries that overlap the batch windows: coverage from src/ledger/coverage-batch.ts, activity from src/ledger/activity.ts, read from the earliest instant the batch’s coverage reaches, less the widest attribution window, so an orphan’s signals are read wherever it is found (Sprint 3.14, 3.16). Entries are filtered deterministically before use: coverage entries with lastSyncAt > evaluatedAt, observations and claims with observedAt > evaluatedAt, adapter syncs with lastSyncAt > evaluatedAt, and activity sessions, idle intervals and entries after evaluatedAt are ignored, so replay at an earlier instant sees exactly what grading saw.

Clock safety (v1.11). Every instant the ledger keeps was read from the one wall clock the service worker, the content scripts and the extension pages share, and a clock that jumped forward and back, or stepped back for good, leaves stored instants after its now, each of which misleads once the clock passes it again: an interval synced “in the future” and merged into a chain vouches for a span its passes never saw (CAL-SIM-2, a false NOT_FOUND), a chain restarted after a step back follows changes from below instants already stamped (CAL-SIM-3), and a row first seen “in the future” refuses every earlier re-sighting until real time catches up (CAL-SIM-7: a five-minute jump cost five hours of Calendar coverage). So: a pass, a ping or a row read after the service worker’s now is not stored (clock_behind), and no coverage interval, first-look marker or Calendar record is stored with a lastSyncAt or readAt after the instant of the transaction that stores it. The ledger keeps the latest instant it stored (its high water, meta). When the clock is below it, before anything of an observer message is stored and before a batch records its instant, the ledger repairs, in one readwrite transaction (src/ledger/clock-guard.ts): every coverage interval synced after now (a window or a marker, of any source) is deleted, so it never vouches for anything and no later pass is merged into it; every Calendar record read after now is deleted, so its range’s chain restarts and no changesSince is carried across; every session ping and time away stamped after now is deleted, and a time away that reaches past now is cut at now, since either would count as UNASKED evidence once real time reached it (§7; Sprint 3 close, SEC-3); every Calendar row first seen after now that no batch has read is deleted, and the next pass derives it again, first seen in the present, with its marker; a row a batch read stays as stored (replay, Hard Rule 4), and an earlier re-sighting of it is a no-op, never late_sighting, so a forward jump no longer locks a range out; every grant stamped after the later of now and the last batch instant + 1 ms is re-stamped there, its fields kept, since a batch ignores a grant stamped after its instant and an agent’s grant is its only row, so until real time caught up every batch grading that agent would throw missing_grant (Sprint 4.4; a saved grant raises the high water like any stored fact); and a clock floor is set at the high water and never lowered: no chain’s changesSince lies below it, so after a permanent step back chains restart from the stamped instant, never from an earlier one. It logs ledger.clock_regressed once, with counts only. Claims and the other rows stay as stored: a batch may have graded with them. Each repair also keeps its span, from now to the high water, in meta (clockRepair.<ms>, v1.16; src/ledger/clock-repairs.ts): every instant a clock that ran ahead may have stamped, or that a clock set back for good will stamp again as an earlier one. Every grading batch passes the spans the ledger keeps to grading in its coverage (Coverage.repairs; when there are more than 200, the oldest are joined into one span, so every instant stays held), so each receipt’s inputsHash and replay record hold them. A grade sees the repairs found by its evaluatedAt (from at or before it), as it sees only the entries synced by then, and §6.1 caps a calendar.created claim whose first-seen bound lies in a span (S2-SIM2-1). A span is purged once it ended before the retention cutoff. Known limit: a jump that comes back before anything is stored and before any batch runs leaves no instant in the future to find; the wall clock alone cannot tell it from real time.

Background obligations (Sprint 3.6, 4.3). The background stamps evaluatedAt first. Then, in one IndexedDB readwrite transaction over the claims, observations, receipts and meta begun after that stamp, it reads the PENDING claims and the stored receipts, passes preAssignments(receipts) as fixed, grades, and writes the results; a background that grades outside that transaction reruns the batch with a new evaluatedAt whenever a receipt it read changed before its write. Inside that transaction, before it reads anything, the batch records its evaluatedAt with recordBatchInstant(db, evaluatedAt) (src/ledger/batch-instant.ts). An instant at or before the last recorded one throws LedgerError('batch_out_of_order'), and the batch does not run. Claims and observations are stored only through upsertClaim and upsertObservation, which set observedAt where the row is written (§10). So no claim or observation is ever stored with observedAt ≤ the evaluatedAt of a batch that did not read it, a row sighted before a batch but delivered after it is stored after that batch’s instant, and a stored row’s observedAt never changes: the first stored sighting is kept and a later one changes nothing; an earlier sighting of a stored observation that arrives later is refused with LedgerError('late_sighting'), while a re-read of a stored claim changes nothing however early it was sighted, because a claim’s sighting grades nothing (§10). An observation stored later than it was sighted also keeps the sighting, as sightedAt (§10): observedAt decides which batches read a row, and the first sighting (sightedAt, else observedAt) is what §4.1’s first-seen rule and the §7 calendar anchor read. An override’s at and a revert’s instant are stamped by writeUserEvent(db, write, { notBefore }) (src/ledger/batch-instant.ts): inside one readwrite transaction over receipts and meta, which also writes the event, at the latest of now (src/clock.ts), the last recorded batch instant + 1 ms and notBefore + 1 ms. notBefore is the receipt’s own last event, lastEventAt(receipt): the latest of its computedAt, its override.at and its last revertedAt, read from the receipt inside a readwrite transaction over receipts and meta that also runs writeUserEvent. So a revert or an override whose clock went back below the receipt’s last event is stamped 1 ms after that event, which transition accepts (§8), instead of being refused (invalid_event); the bound only moves an instant later, past an event the receipt already holds. That transaction runs before or after a batch’s own, never inside it, so an event written after a batch is stamped after that batch’s instant even when the clock went back, and a batch never runs without an event replay puts in force for it. Batches never run concurrently. Then every override stamped before a batch’s evaluatedAt is in what that batch read; one stamped at or after it releases or takes nothing in that batch, on either side; a claim overridden from PENDING while a batch ran is left out of it with its stored evidence fixed; and replay rebuilds exactly the batch that ran (Hard Rule 4). Each batch’s evaluatedAt is strictly after the previous batch’s. The gcal.event coverage a batch reads is closed under overlap (v1.9): with any gcal.event entry it holds, it holds every other one synced by evaluatedAt whose window overlaps it, whatever its account, first-look markers included (v1.10), so the orphan rule (§3, above) never misses the first look that keeps a row from accusing an agent: a reader that returned only some of them could let another account’s first-look rows through as orphans. The background passes a PENDING claim to a batch only when gradedAt(receipt, evaluatedAt) (batch-fixed.ts) admits it: a claim whose reverted override is still in force (revertedAt ≥ evaluatedAt) is left out like an overridden one. A PENDING claim-less row the batch no longer produces is deleted unless it holds reverted overrides (§8). A superseded row is never regraded. Since the Sprint 3 close a batch skips, reading and writing nothing past its instant, when no receipt is PENDING and no fact was stored since the last batch that graded (tech review TECH-2): every stored sighting, sync, Calendar read, health report and activity row counts one in meta storedWrites, in the transaction that stores it, and a batch that grades records the count it saw (gradedWrites); with nothing PENDING and nothing new, grading would write nothing, since no rule reads the clock but finality. The activity a batch hands grading holds only the claims within the spend window before a claim it grades or near an orphan it can find (less a day for a claim whose precision unit is a day), and a list that still holds more than SCHEMA_LIMITS.GRADING_INPUT items is cut to the newest with a warning naming the list, never silently (TECH-9). A grace-alarm batch that changed a receipt tells open pages, as an import’s does (TECH-8).

Grant changes (Sprint 4.4). A person changes an agent’s grant in the options page, which sends only what they chose (grant.save: allowed actions, recipients, spend ceiling and its currency, declared access) to the service worker, the ledger’s one writer. In one readwrite transaction over grants and meta (src/ledger/grant-save.ts) it keeps what Attest observed from the stored grant (observedScopes, accounts), so a page read before an observed change never puts back what that change replaced, and stamps updatedAt at now, or 1 ms after the last batch instant when the clock reads at or before it, as a user event is stamped. The grant in force in a batch is the latest one stamped at or before its evaluatedAt (grantsInForce, src/core/batch-grants.ts), so a saved grant applies from the next batch on and never to one that ran before it. The save is a stored fact (it raises the stored-fact count and the high water), so the batch that follows it grades even when no receipt is PENDING, and then a batch runs at once. A batch grades only claims whose receipt is still PENDING (and orphans and scope subjects found anew), so a grant change regrades only the agent’s open (PENDING) receipts: a tighter grant turns an open claim OVER_SCOPE (scope.type_not_allowed, scope.counterparty_not_allowed, ceiling.exceeded, ceiling.cumulative_exceeded), a narrower declared access adds an over_scope_observed receipt for an observed scope it no longer declares, and a looser grant changes an open receipt it no longer puts out of scope. A FINAL receipt is never regraded (regrade refuses it, §8): what a grant decided stays decided, and its receiptInputs keep the grant it was graded under, so replay finds the same grade. The person’s alert mute for that agent is saved beside the grant, never in it (meta alertsMuted.<agent>); grading never reads it.

The model’s scores (Sprint 5.4). The advisory model is asked outside every batch, never inside one, so no receipt waits for it (src/background/intel.ts). After a batch, while the person has a model on, the background asks it about each claim whose receipt is PENDING by rule partial (its floor at or above thresholds.partial and below verified: the one outcome §5.4 lets a score change), with the one candidate the claim took, the only one whose score a grade reads. Each claim is asked once: its modelAnswers row is written before the call, and a call that fails or takes longer than its two seconds leaves the row without scores for good. An answer is stored as a fact (it raises the stored-fact count and the high water), then one more batch runs at once. Every batch passes each claim it grades the scores the ledger holds for it (gradeBatch’s modelScores), while the person has a model on; turned off, the scores count no more, and the open receipts are graded rules-only again. The scores ride in the claim’s context, so its receiptInputs hold them and replay applies exactly them (§9). A receipt already FINAL is never regraded (§8), so a match first seen after its grace is graded by the rules alone, and the model is not asked about it.

Hashes. inputsHash = SHA-256 of the canonical JSON (§10) of {claim, candidatesHash, grantHash, policyHash, coverageHash, context}. candidatesHash is Merkle-style: each candidate’s refHash = SHA-256 of the canonical JSON of {id, contentHash, observedAt, sightedAt?} (computed once per observation, not per receipt; sightedAt is bound when the row has one, and canonical JSON drops it when it has none, so a row without it hashes as it did before v1.8), and candidatesHash = SHA-256 of the concatenation of the candidates’ 32-byte refHash digests, in id order (the empty list hashes the empty byte string). It binds exactly the same facts as listing the refs, without re-encoding them for every receipt: where grantHash and coverageHash are the SHA-256 of the canonical JSON of the grant and of the coverage after every set-like array is sorted and de-duplicated (coverage.observers, grant.allowedTypes, counterpartyAllowlist, declaredScopes, observedScopes, accounts lists), and candidates are de-duplicated by id and sorted by id. An entry’s changesSince is bound when set; canonical JSON drops it when absent, so an entry without it hashes as it did before v1.9. A first-look marker (v1.10) adds no field: it is an entry like any other, and is bound the same way. A candidate’s content is pinned by its contentHash (and its id derives from it), so naming it by {id, contentHash, observedAt, sightedAt?} covers every input exactly; sub-hashes are computed once per batch (and refHash once per observation). It is stored and proves replay.

Receipt id. Stable per subject, not per input: id = idFromHash(sha256Hex(canonicalJson(K))) with K = {k:'claim', claimId} for a claim receipt, {k:'unasked', observationId} for UNASKED, {k:'scope', agent, scope} for a scope-diff receipt. A regrade replaces the PENDING row with the same id (idempotent; same claim → same row). computedAt = coverage.evaluatedAt; finalAt = the evaluatedAt of the first grading that met the FINAL condition; until then, finalFrom = the instant from which a grading can make it FINAL (§8, v1.18).

4. Candidates (candidates.ts)

4.1 Window

Let t = claimedAt and p = claimedAtPrecision (minute | hour | day; the unit containing t in t’s own UTC offset).

A candidate is an observation of the same ActionType, from a source the policy lists for that type (policy.sourcesByType[claim.type], the relevant sources of §6.1: evidence the claim’s coverage never reads cannot verify it), whose occurredAt lies in the window (inclusive). An observation from any other source is never a candidate of the claim (grade refuses it, GradeError('foreign_candidate')), and it is never an orphan either (§3). Known limit of the first-seen rule below: the ledger never moves a stored row, so when a second tab’s earlier sighting of an event arrives after the first tab’s row was stored, it is refused (late_sighting, the pass DEGRADED) and the stored row’s later first sighting stands. A calendar.created claim that the earlier sighting would fail can then be VERIFIED; the measured rate is in Decisions.md ([1.close-r4]). For calendar.created, a candidate also needs to have been first seen at or after start(p,t) − beforeClaimMin, comparing the earliest first sighting of all observations sharing its contentHash (an event already on the calendar before the claim cannot verify “created”). An observation’s first sighting is its sightedAt when it has one (the observer’s sighting, kept by the ledger when it stored the row later, §10), else its observedAt; order.ts firstSeenAt is the one reader of it (candidates, batch retrieval, grade’s own check, the §7 anchor and the ledger’s late_sighting). The stored observedAt is no sighting: the ledger sets it no earlier than the write and after the last batch (§3), so an event already on the calendar but written just after a batch would otherwise look created after the claim. Duplicates by contentHash (hence by id) count once: the one first seen earliest is kept (then the earliest observedAt, then the smaller observedAt and sightedAt text, order.ts representative). Candidates are ordered by (occurredAt instant, id, contentHash); the schema requires Observation.id = idFromHash(contentHash).

First looks and change rows (v1.9). The Calendar observer finds changes by comparing snapshots (§3). Its first look at a range compares nothing and reports each event already there as a calendar.created row first seen then (§10), stored with its first-look marker (§3, v1.10). Such a row is a candidate of a calendar.created claim like any other row, under the first-seen rule above: a first look at or after start(p,t) − beforeClaimMin shows the claimed event, whether or not a view saw it appear, and an event a view saw before that bound is never “created” by the claim. Known limit: a first look cannot show that the event was new, so an agent that claims to have created an event the user made earlier is VERIFIED when Attest first looks at that range only after the claim; had Attest looked at the range before the claim, the event’s first sighting would fall before the bound and the claim would not verify. calendar.updated and calendar.deleted rows come only from comparing two snapshots, and a claim of either type is never fully covered (§6.1, v1.11): a change row verifies it, and nothing makes it NOT_FOUND. A calendar.created claim is fully covered only by views last synced at or after end(p,t) + graceMin (§6.1, v1.10). A change noticed late is stored as a first sighting (§3, v1.11) and is a candidate like any row of its type.

4.2 Retrieval

ObservationIndex is the only way core reads observations, and every read is bounded in time: byTypeAndBucket(type, fromMs, toMs) and byCounterpartyToken(token, fromMs, toMs), both async, both allowed to over-return; candidates() filters exactly. Counterparty tokens are the observation’s address read through email() (never the display name), its domain, and its local-part tokens split on [._-] without the +tag, all ASCII-lowercased, plus the merchant’s name tokens; lookups are exact. An in-memory index ships in core for tests and replay; the Dexie index (Sprint 2.6) passes the same test file.

4.3 Assignment: one observation verifies at most one claim

A batch = every PENDING claim plus FINAL and USER_OVERRIDDEN claims as fixed pre-assignments (fixed: observation → claim, each pair with the batches it is in force in, after < evaluatedAt ≤ until and outside its released spans (from, to], derived from the stored receipts by preAssignments in batch-fixed.ts, the one rule gradeBatch and replay share. A claim’s pairs are in force only after its receipt’s computedAt, its last grading. A FINAL receipt fixes its observationIds; an override to VERIFIED keeps them, and an override to NOT_FOUND releases them in batches evaluated after its at (a released span from at with no end). A receipt PENDING until an override to VERIFIED fixes its stored observationIds from its computedAt on as well: every later batch left the claim out after reading the override, and the evidence left free could be taken FINAL by another claim, then found fixed to two claims. One overridden to NOT_FOUND fixes nothing. An overridden UNASKED receipt fixes its observation as taken in batches evaluated strictly after its override’s at (and its computedAt); a FINAL UNASKED receipt does not: its observation stays assignable, and a claim that takes it supersedes it, §7). A reverted override (§8 reverted) stays in force for the batches evaluated up to its revertedAt. An override from PENDING to VERIFIED fixed its evidence from the later of its grading and the revert before it since that grading, up to its revert (until). An overridden UNASKED receipt took its orphan in (at, revertedAt]. An override from FINAL to NOT_FOUND released the FINAL evidence in (at, revertedAt]. A revert fixes evidence again only if no other claim holds that observation then (batch-fixed-claims.ts). Each claim’s spells are kept in the order they begin, a grading before a revert at one instant, then by claim id. A spell that begins at a revert while another claim’s kept spell holds the observation is dropped, and released for good. The reverted receipt then shows its computed grade again while another claim holds its evidence, so two receipts name one observation (Sprint 4.3: the revert control says so, or refuses that revert; the measured rate is in Decisions.md). A spell that begins at a grading is never dropped: two that overlap are a corrupt ledger, and assign refuses it. A claim pair’s after is always a grading of its claim. Only eligible claims take part in assignment: a claim that is underspecified (§5.2), a calendar claim without targetAt, or a claim whose type has no sources is never scored against observations, so a vague claim can neither take evidence from a specific claim nor hide an orphan from UNASKED. Every (eligible claim, candidate) pair is scored (§5). Pairs with score ≥ thresholds.partial are assigned greedily, best score first (exact comparison, §5.3); ties break by earlier claimedAt instant (epoch ms, never strings), then claim id, then observation id. One-to-one: each observation goes to at most one claim and each claim takes at most one observation. Fixed pairs are taken first and never moved (assign(pairs, policy, fixed, taken): taken observations are never assigned; fixed pairs that are not one-to-one throw CandidatesError; an observation both fixed to a claim and taken stays with that claim and is excluded from every other claim and from the orphans). A claim’s candidates for grade are its own candidates minus observations assigned to other claims or fixed as taken. Observations assigned to no claim are orphans (§7).

5. Features and score (features.ts)

5.1 Features

Each feature value is an exact rational in [0, 1]. Counterparty normalisation is §11.

Feature Value
timeProximity Δ = the distance in ms from occurredAt to the interval [start(p,t), end(p,t)] (0 inside it); for calendar, the distance to targetAt. edge = beforeClaimMin on the before side, afterClaimMin on the after side, calendarDays for calendar. Value 1 when Δ ≤ toleranceMin, else (edge − Δ) / (edge − tolerance), all in ms. The loader rejects a policy whose toleranceMin is not below every edge.
counterpartyExact Claim counterparty is an address: 1 iff the two addresses are code-point-identical after ASCII-only lowercasing (no NFC, no NFKC). Identical confusable addresses (any non-ASCII code point or an xn-- label) score 1 with reason cp.non_ascii. If either address is confusable (§11) and they are not identical, the pair is confusable: 0, reason cp.confusable. Otherwise a +tag-only difference scores 0 with reason cp.plus_tag (cp.confusable replaces cp.plus_tag when both apply). cp.* reasons come only from address claims.
counterpartyDomain Claim counterparty is an address: 1 iff counterpartyExact = 1, or (the domains are identical, neither address is confusable, and (the domain ∉ policy.commonDomains, or the addresses differ only by a +tag)); else 0. A free-mail domain alone says nothing about identity, but an exact or same-mailbox address still counts.
counterpartyName Claim counterparty is a name (not an address) with at least one token: 1 iff every token of the normalised name is a token of the observation address’s local part, ASCII-lowercased (never NFKC’d) and split on [._-] after removing a +tag. The display name is never used: it is attacker-controlled. Else 0 (also 0 when the observation has no address).
subjectOverlap Jaccard |A∩B| / |A∪B| of the subject token sets (§11). Not applicable when the claim has no tokens; an observation with no tokens scores 0.
amountExact Applicable when the claim has amountCents. 1 iff amountCents are equal and both currencies are present and equal; a currency missing on either side scores 0.
merchantMatch 1 iff the claim’s normalised counterparty equals the observation’s normalised merchant; else Jaccard of their name tokens (0 when the observation has none). Merchant text is read through counterparty(); text it rejects has no name tokens (claim: not applicable; observation: 0).
modelScore Never a pair feature: features() never applies it, and no type lists it (the loader refuses one that does). It is the advisory model’s score of the best candidate, which may raise that candidate’s score after the floor chose it (§5.4, v1.19).

Observation counterparties are always read through email() (§11) before comparison: observers store the address only, but a display-name form that slips through is reduced to its address and never compared by name.

5.2 Applicability and identity

policy.featuresByType[type] lists the features for a claim type. Applicability is decided by the claim alone: a listed feature is dropped when the claim lacks its field or the field has no tokens (and the address/name split above picks exactly one of counterpartyExact+counterpartyDomain or counterpartyName). A missing or empty field on the observation scores 0, never “not applicable”. So no observation can make a claim easier to match by omitting data.

Identity. policy.identityFeatures[type] lists the features that say which action a claim means (email: counterparty; calendar: subject or counterparty; purchase: amount or merchant). If the type’s identity list is non-empty and none of its features is applicable, the claim is underspecified (“Done! I’ve sent the email.”) and is graded by rule underspecified → UNVERIFIABLE with reason claim.too_vague, never matched on time alone. timeProximity is never an identity feature: time says when, never which action, and the loader refuses it in identityFeatures (v1.11, §5.3).

5.3 Exact score

The policy loader converts every weight, threshold and unasked signal weight to integer basis points and rejects any value that is not a multiple of 0.0001. Feature values are rationals pᵢ/qᵢ with integer numerator and denominator. The score is the rational S = Σ wᵢ·pᵢ/qᵢ ÷ Σ wᵢ over applicable features, compared with a threshold T (bp) by cross-multiplication in BigInt: S ≥ T ⇔ 10000 · Σ wᵢ·pᵢ·(Q/qᵢ) ≥ T · Σwᵢ · Q, Q = Π qᵢ. Assignment ties compare the same rationals. A featuresByType entry whose weights sum to 0 is rejected at load, and so is an identityFeatures entry naming a feature of weight 0, so every claim that is not underspecified has Σwᵢ > 0; the loader also rejects a type with sources whose identityFeatures list is empty. The loader requires 0 < thresholds.partial < thresholds.verified in basis points: a score of 0 is no evidence (§2), so a partial threshold of 0 would assign, name as evidence and grade PARTIAL an observation that matches nothing (thresholds.partial: must be > 0). Likewise unasked.threshold must be at least 1 bp (unasked.threshold: must be > 0). UNASKED names its evidence, the signals that fired (§7), so at 0 an attribution whose fired signals weigh nothing would still be ‘likely’ (Hard Rules 5, 7). features() throws RatioError('zero_weight_sum') if called on a claim with no weighted applicable feature; callers use it only for assignable claims. Windows are whole minutes, hours or days (integers ≥ 0, safe in ms). A rule may have then: PENDING only if its id is pending. A rule may conclude a grade only if it names that grade’s evidence (§6). signals.score and the feature values on a receipt are display numbers rounded to 4 decimals and are never compared. Summation order cannot change a grade.

Content gate: no evidence on time alone (v1.11). A pair’s score is 0 unless at least one of its applicable content features scores above 0: every feature but timeProximity, that is counterpartyExact, counterpartyDomain, counterpartyName, subjectOverlap, amountExact and merchantMatch (modelScore never applies). Otherwise the score is the weighted mean above, unchanged. Time says when, never which action, so a pair that matches on time alone is no evidence: its score 0 is below thresholds.partial (> 0), it is never assigned and never PARTIAL or VERIFIED, and its observation stays free for its own claim or as an orphan. The Calendar simulator’s CAL-SIM-5: a false claim of a ‘Dentist’ event took a real ‘Board prep’ event three hours away as PARTIAL evidence, FINAL, on time alone (2500 · 4140/4319 / 4000 = 5175/8638 ≈ 0.599, subject 0), and the true claim for ‘Board prep’, read later, found its only candidate fixed to the false one and was NOT_FOUND (golden 111). The gate reads every content feature, not only the type’s identityFeatures (§5.2): an email to a colleague of the claimed recipient at the same domain (golden 02) and a name claim whose name is not in the address but whose subject matches (goldens 19, 54, 71) keep their PARTIAL, because NOT_FOUND would say that nothing matched what partly did (Hard Rule 5). Every claim that is not underspecified has an applicable content feature (its identity features are content features), so the gate never leaves a claim nothing could match. features() still throws RatioError('zero_weight_sum') where the mean is undefined, gate or not. The receipt’s signals keep each feature’s value; signals.score is 0 (features-parts.ts contentMatched; RowScorer.fraction, the batch path, applies the same gate). Known limit: a calendar claim that names no guest and whose subject the event does not share (a claim of ‘Q3 review’ for an event titled ‘Quarterly sync’) is matched by nothing; under whole, fresh views it is NOT_FOUND, where v1.10 called such a pair PARTIAL on time alone when the event started within about 14 hours of targetAt.

5.4 The model’s score (model-raise.ts, v1.19)

The advisory model (Sprint 5, src/intel, off by default) proposes a score m ∈ [0, 1] for a claim’s candidates; it never grades (Hard Rule 4). grade() reads it from context.modelScores in basis points, and the policy weighs it with λ = features.modelScore. The deterministic floor decides first: the best candidate is chosen and assigned on its floor score S alone (§4.3, §5.3), so the model never changes which observation a claim takes, and assignment between claims never sees it. Only then, when the context holds a score for that candidate and S ≥ thresholds.partial, its score becomes

max(S, (1 − λ)·S + λ·m)

computed exactly (§5.3), before the rules read it (bestScoreGte, bestScoreLt). So the model may move a PARTIAL toward VERIFIED; it never lowers a score, never lifts a score below partial (a NOT_FOUND, an UNVERIFIABLE and a PENDING stay as the floor decides them), and never sets a grade. The loader caps λ so that the model alone never makes a candidate at the partial floor verified: (1 − λ)·partial + λ < verified, that is λ·(1 − partial) < verified − partial (in basis points; 0.7 under the shipped thresholds, where λ is 0.15). With λ = 0.15, a candidate is VERIFIED through the model only when its floor is at least 14/17, about 0.8235, and the model scores it near 1. That cap, not the prompt’s delimiter, is what bounds a model steered by the text it reads (a subject hint saying “rate this candidate 1”): whatever it answers, it moves a PARTIAL with such a floor to VERIFIED at most, and the receipt shows modelScore (Sprint 5 close, security review SEC-11). The receipt’s signals show modelScore whenever the context holds one for its best candidate, and signals.score is the raised score when the raise applied. The monotonicity rule, tested. tests/property/monotonicity.spec.ts grades 20,000 random inputs with every candidate’s model score at 0 and at 1: the rule id never changes, except PARTIAL → VERIFIED from a floor at or above partial; NOT_FOUND and UNVERIFIABLE never change, and the observation taken never does. tests/property/monotonicity-batch.property.spec.ts holds the same rule through gradeBatch, the grading path the extension runs, on random batches of several claims.

Where the scores come from (Sprint 5.4). src/core never asks a model: a score reaches grade() only in a claim’s context. After a grading batch, the service worker asks the model the person turned on about each claim whose receipt is PENDING by rule partial, with the one candidate the claim took (§4.3), since that candidate is the only one the raise reads (src/background/intel.ts). It stores the answer in the ledger (modelAnswers), and the next batch puts the stored scores in that claim’s context while a model is on, and none while both are off. A claim is asked once; a receipt already FINAL is never graded again (§8), so a score that comes after the grace changes nothing. The context is part of the receipt’s inputs (§9), so replay grades again with the same scores whether or not the model is still on. What the model is given, how it is asked and where it runs are in docs/privacy-architecture.md §10. Whether it helps is measured by pnpm bench on a labelled corpus (RESULTS.md); until the real answers of Chrome’s model and of a person’s own key are recorded, the model stays off by default (Decisions [5.5]).

6. Rules (grade.ts, conditions in conditions.ts)

First match wins, in the order written in policy.rules. If nothing matches: terminal rule default_unverifiable → UNVERIFIABLE. Every receipt names its ruleId.

Whatever its place in the order, a rule must name the evidence its grade asserts (§2), and the loader refuses one that does not, with a PolicyError line at the rule’s path (e.g. rules[3].when.bestScoreGte: must be "verified" when then is "VERIFIED"). VERIFIED needs bestScoreGte: verified. PARTIAL needs bestScoreGte (either threshold). NOT_FOUND needs graceElapsed: true, coverage: full and bestScoreLt: partial. UNASKED needs unaskedLikely: true. OVER_SCOPE needs at least one of observedScopeExceedsDeclared, typeNotInGrant, counterpartyNotAllowed, amountAboveCeiling, cumulativeAboveCeiling set to true. UNVERIFIABLE and PENDING need none. The default order (§6.2) already names exactly these (Hard Rules 5, 7).

6.1 Conditions

“Relevant sources” = policy.sourcesByType[claim.type]. An entry is eligible for a claim if its source is relevant, lastSyncAt ≤ evaluatedAt, and its account is acceptable: if grant.accounts[source] is set, accountHash must be in it; if not set, an entry with no accountHash, or a source seen with ≥ 2 distinct account hashes, counts at most as partial coverage. A gcal.event entry without allCalendars: true counts at most as partial.

Per-account coverage (v1.11). When grant.accounts[source] binds two or more distinct accounts, each eligible entry keeps its account, and the source is full only when every bound account’s own eligible, uncapped entries contain the window: one account’s view never vouches for another’s calendars or mail. allCalendars is a fact about one account’s calendar list, and an agent bound to two accounts may have acted in either. The Calendar simulator’s CAL-SIM-4: account B’s whole week, every calendar of B shown, made a claim NOT_FOUND, FINAL, for an event the agent had created in account A’s calendar, which Attest never saw that week. A window that only the bound accounts’ views together contain (each covering a part, or one covering all of it and another not) is partial, with reason coverage.account_partial {source} (conditions-level.ts fullyCovered); one where even the union falls short is partial with the caps its entries carry. Every source alike (golden 110 has a Calendar and a Gmail Sent pair). A grant binding one account, or one account named twice, is unchanged, and an unbound grant keeps the caps above. Known limit: a claim under a grant that binds an account Attest never sees is never NOT_FOUND (UNVERIFIABLE once it finalises by time), whatever the other account shows.

Calendar change claims are never NOT_FOUND (v1.11). For a calendar.updated or calendar.deleted claim, every gcal.event entry over the window counts at most as partial, with reason coverage.change_unprovable, however whole, fresh and followed it is. Calendar is read as snapshots: a change is seen only as a difference between two of them, so a change made between two looks and then moved again, changed again or removed (CAL-SIM-6: ‘Standup’ moved twice while the tab was closed, the first move claimed and never compared), or one that reached the ledger as rows of other types (CAL-SIM-1: an event moved between two followed ranges is a calendar.created row in one and a calendar.deleted row in the other; a create then edit between two passes is one calendar.created row), leaves no row of the claimed type, and no view can say the change was never made (Hard Rule 5). A change row a view did see still verifies or is PARTIAL evidence, because verified and partial come before partial_coverage (§6.2); a claim with none is partial_coverage (or pending before its grace sync, or no_coverage with no entry over its window), UNVERIFIABLE once it finalises by finalizeUnverifiableHours (§8; goldens 102, 103, 109). v1.9 and v1.10 counted an entry that had followed changes over the claim’s change span [start(p,t) − beforeClaimMin, end(p,t) + graceMin] towards full coverage, capping any other with coverage.changes_unfollowed; that rule and conditions.ts changeSpan are gone, and no coverage level reads changesSince (the orphan rule still does, §3, §7). The cap reads the claim’s type, so it is applied when the claim’s coverage is computed, never in the eligibility a batch shares between claims. A capped entry still counts for grace (the observer synced). Known limit: a false claim that an agent changed or deleted an event is never NOT_FOUND: it is reported UNVERIFIABLE with coverage.change_unprovable, never “all clear” (Hard Rule 5), and a true one whose change reached the ledger as rows of other types is UNVERIFIABLE too, never VERIFIED.

Views looked at too early (v1.10). For a calendar.created claim, a gcal.event entry also counts at most as partial (reason coverage.changes_unfollowed) unless its lastSyncAt is at or after end(p,t) + graceMin, the claim’s grace sync (conditions.ts calendarBound; a change claim’s entries are all capped by the rule above). A view last looked at before the claimed event could have been created cannot say it never was: the October view synced at 14:00, then the user moved to November; a claim at 14:02 that an October 15 event was created is partial_coverage, PENDING, not NOT_FOUND, until the October view is synced again at or after 14:33 (golden 108, Hard Rule 5). Like the change-span cap, it is per entry and per claim (each entry’s own lastSyncAt: a stale view of one part of the window and a fresh view of the rest are never pieced together), a capped entry still counts for grace, and a row a stale view saw still verifies. It relies on the §3 merge rule: a merged entry’s lastSyncAt is never moved over days the later pass did not see again.

Claims stamped across a clock repair (v1.16). For a calendar.created claim whose first-seen bound start(p,t) − beforeClaimMin (§4.1) lies in a clock repair’s span (coverage.repairs, both ends included; §3 Clock safety), every gcal.event entry over the window also counts at most as partial, with reason coverage.clock_repaired, however whole, fresh and followed it is (conditions.ts calendarBound, repaired). The span holds every instant a clock that ran ahead may have stamped, or that a clock set back for good stamps again as an earlier one. A claim stamped there, at 11:05 by a clock three hours ahead, has its event first seen after the repair, at 09:20 by the right clock: earlier than the claim’s bound, so the first-seen rule drops it, and a whole, fresh view with no candidate would say NOT_FOUND although the event was created (S2-SIM2-1; golden 114). A claim whose bound lies outside every span keeps NOT_FOUND: every sighting of its event, before or after a repair, reads as at or after it. So does a claim of day precision whose day began before the span (its bound is the start of its day). A row first seen at or after the bound still verifies or is PARTIAL evidence, because verified and partial come before partial_coverage (§6.2). Like the change claims’ cap, it reads the claim, so it is applied when the claim’s coverage is computed, never in the eligibility a batch shares between claims, and a capped entry still counts for grace. Known limit: a false calendar.created claim stamped in a repaired span is never NOT_FOUND: it is UNVERIFIABLE with coverage.clock_repaired, never “all clear” (Hard Rule 5).

Sent lists looked at too early (v1.14). Every claim caps each gmail.sent entry at partial (reason coverage.list_unsettled) unless its lastSyncAt is at or after the claim’s grace sync, max(end(p,t) + graceMin, end(p,t) + afterClaimMin) (conditions-level.ts sentCap). The Sent list in an open tab shows a send made elsewhere (an agent’s reply through the API) only once the tab’s list refreshes, under its conversation’s newest minute, and a conversation row is never a row (§3, v1.13): a pass that read the list before the refresh vouched for a span holding a reply it never showed, and a later pass’s window starts after that conversation, so their union would make a true reply claim NOT_FOUND. With the cap, an email claim is NOT_FOUND only on views synced after its grace sync (golden 113, Hard Rule 5); a send an early view saw still verifies, and a capped entry still counts for grace. A view capped for being early (coverage.changes_unfollowed, coverage.list_unsettled) is named among the cap reasons only when some part of the window it saw is seen by no later, uncapped-by-time view: one a later view contains adds nothing. The §3 merge rule bounds gmail.sent unions as it bounds gcal.event ones.

What NOT_FOUND says of a calendar.created claim (v1.11). In views of the claim’s whole window, each synced at or after end(p,t) + graceMin, every calendar shown, of the bound account (of each bound account, above), no event matching the claim at thresholds.partial, and not held as evidence by another claim (§4.3), was on the calendar that Attest had not already seen before the claim (§4.1’s first-seen bound): the event the agent says it created was not there when Attest looked. It is a statement about the calendar at those views, not about every instant before them. Known limits: an event created and then deleted, moved out of the window or renamed past matching before the first fresh view is NOT_FOUND by that meaning, although it briefly existed; and an event Attest saw before the claim is no evidence that the claimed action created it, so the claim is NOT_FOUND when nothing else matches (golden 52).

First-look markers (v1.10). A first-look marker (§3) is a point at its row’s start: it never contains a claim window and never fills a gap between two entries (a gap between two closed intervals is an open stretch of time, which no point fills), so a marker never makes coverage full, and its window never takes coverage from full. Over a window no other entry overlaps, it makes coverage partial instead of none (UNVERIFIABLE either way, with its own caps). Its account counts towards the account rule above like any entry’s, wherever its point lies: a marker of a second account (the batch’s coverage holding entries of another one, and none of its own) makes the source seen with two account hashes, so for a grant that binds no gcal.event account every gcal.event entry is capped (coverage.multiple_accounts), as that account’s own windows would cap it. That is the only way a marker lowers a level (towards UNVERIFIABLE: Attest cannot tell which account the agent used, Hard Rule 5); it never makes one full. Like any entry it counts for grace: its lastSyncAt is the instant the pass that stored its row synced. Under a grant binding two or more accounts, a marker never stands for its account’s view (a point contains no window, v1.11).

Receipt points (v1.9). A gmail.receipt entry covers one message’s instant (§3). A point never contains a claim window (§4.1 windows are at least a minute long), so a purchase-like claim’s coverage is partial at most, with the cap reasons its entries carry or none: never full, never NOT_FOUND, while an exact receipt still verifies. Its VERIFIED finality (§8) waits for a receipt pass synced at end(p,t) + graceMin, which with points is the next receipt the user opens at or after it (any message: finality reads the sync, not the window).

Condition True when
coverage: 'none' No eligible interval overlaps the claim window. A type with no sources (message.sent, other) is always none. For a purchase-like type (its sources are ["gmail.receipt"]) whose normalised counterparty (name(), §11) is not in policy.receiptMerchants, whose counterparty text is rejected (§11), or which has no counterparty, coverage is at most partial (reason coverage.no_parser): no parser could have seen the receipt, so Attest can never say NOT_FOUND, but a receipt it did see still verifies. In general the cap applies per entry: for any claim whose type reads gmail.receipt and that has no parser, every gmail.receipt entry is capped, so a mixed-source type can still be full only through its other sources and never through receipts no parser could read (Hard Rule 5). The shipped policy has no mixed-source receipt type.
coverage: 'partial' Eligible intervals overlap the window but their union does not contain all of it (or only capped entries contain it, a gcal.event entry capped for a calendar claim by coverage.changes_unfollowed, coverage.change_unprovable or coverage.clock_repaired included; or, for a source the grant binds to two or more accounts, some bound account’s own uncapped entries do not, with coverage.account_partial when the union does).
coverage: 'full' The union of eligible, uncapped intervals contains the whole window, and, for a source the grant binds to two or more accounts, so does each bound account’s own (v1.11). Never for a calendar.updated or calendar.deleted claim (v1.11). With several relevant sources, levels are per source: the claim’s coverage is full iff every relevant source is full, none iff every one is none, else partial.
typeNotInGrant claim.type ∉ grant.allowedTypes.
counterpartyNotAllowed claim.type ∈ policy.allowlistTypes, grant.counterpartyAllowlist is non-empty, and any known address — the claim’s (if it is an address) and the assigned candidate’s (if there is one) — matches no entry. Entries and domains compare after ASCII-only lowercasing; an entry containing @ is an address and matches only an identical address (§5.1); any other entry is a domain, matching an equal domain or one ending in .entry. A confusable address never matches. With no known address at all (the claim’s counterparty is not an address, and there is no assigned candidate or it has no address read through email()): not applicable (reason allowlist.unchecked).
amountAboveCeiling Not applicable unless the guard holds: claim.type ∈ policy.spendTypes, grant.spendCeilingCents is set (then grant.spendCurrency is required by schema), and an amount is known (the claim’s or the assigned candidate’s amountCents, in any currency); within the guard it is true or false. A candidate with no currency neither counts toward the Amount nor mismatches. Amount = the larger of the claim’s amountCents (counted only when it is counted in spendCurrency: the claim’s currency is absent or equals spendCurrency) and the assigned candidate’s amountCents when that candidate’s currency equals spendCurrency. True if amount > ceiling (reason ceiling.exceeded), or if the claim’s currency is present and ≠ spendCurrency, or the assigned candidate’s currency is present and ≠ spendCurrency, whatever the claim’s currency (reason ceiling.currency_mismatch, which replaces ceiling.exceeded unless the amount also exceeds the ceiling in spendCurrency; the candidate case finalises like PARTIAL). Otherwise false.
cumulativeAboveCeiling Not applicable unless claim.type ∈ policy.spendTypes, grant.spendCeilingCents is set, the claim’s amountCents is known, and the claim’s amount is counted in spendCurrency (its currency is absent or equals spendCurrency: the same reading as the claim side of the amountAboveCeiling Amount). Within the guard, true when context.priorSpendCents + claim.amountCents > spendCeilingCents (exact).
observedScopeExceedsDeclared (claim null) context.scope is set and the observed scope is not in grant.declaredScopes. With context.scope absent: not applicable.
targetMissing claim.type is calendar.* and claim.targetAt is absent.
underspecified §5.2.
bestScoreGte: T the best candidate’s exact score ≥ thresholds[T]. Not applicable to a claim that is never scored against observations (§4.3: underspecified, a calendar claim without targetAt, a type with no sources).
bestScoreLt: T the best candidate’s exact score < thresholds[T] (true when an assignable claim has no candidates). Not applicable to a claim that is never scored (§4.3), so whatever the rule order, not_found and partial_coverage never assert that nothing matched a claim nothing was matched against (Hard Rule 5).
graceElapsed every relevant source has an eligible entry with lastSyncAt ≥ end(p,t) + graceMin and, for non-calendar types, also ≥ end(p,t) + afterClaimMin (the whole window has had time to happen). Vacuously true for a type with no sources. The clock is the observers’ sync time, never a wall clock: Attest says NOT_FOUND only after it actually looked.
unaskedLikely (claim null) context.attribution is set and its score ≥ policy.unasked.threshold (exact, in bp). With context.attribution absent: not applicable.

Eligibility also caps coverage for accounts: the account-hash count is taken over this source’s entries in coverage.observers, and coverageFor (Sprint 2.6) must return all of them. A NOT_FOUND graded without grant.accounts for that source carries reason coverage.account_unbound: if the user only ever opens the wrong account, Attest cannot tell (listed in docs/attacks-that-work.md, red-team strategy 7). The Sprint 3 settings-page adapters record the agent’s account hash so grant.accounts can be filled.

All conditions in one when must hold. Claim-less evaluation: when claim is null, every condition except unaskedLikely and observedScopeExceedsDeclared is not applicable, and a rule with any non-applicable condition does not match; when a claim is present, those two are not applicable. Because VERIFIED, PARTIAL and NOT_FOUND rules must name bestScoreGte, graceElapsed, coverage or bestScoreLt (§6), none of them can match a claim-less grade, and an UNASKED rule (which must name unaskedLikely: true) can never match a claim.

6.2 Default rule order

unasked → over_scope_observed → over_scope → over_allowlist → over_ceiling → over_ceiling_cumulative
→ no_target → underspecified → no_coverage → verified → partial → pending → partial_coverage → not_found

with not_found.when = {graceElapsed: true, coverage: 'full', bestScoreLt: 'partial'}, pending.when = {graceElapsed: false}, partial_coverage.when = {coverage: 'partial', bestScoreLt: 'partial'}. no_target and underspecified are claim-intrinsic (a calendar claim without a target has no window to cover), so they come before no_coverage and their reason is the accurate one. Over-scope rules read only the claim, the grant and (for amounts and allowlists) the assigned evidence, so they come before no_coverage: an agent that claims a $200 purchase against a $50 ceiling is OVER_SCOPE whether or not a receipt email is visible. Hard Rule 5 forbids VERIFIED and NOT_FOUND without sight; it does not forbid OVER_SCOPE on the agent’s own claim.

6.3 Cumulative spend (scope.ts)

priorSpendCents(claim, claims, policy, spendCurrency?) sums amountCents of the same agent’s claims whose type is in policy.spendTypes and whose amount is counted in the claim’s currency. A missing currency, on the claim or on a prior, reads as spendCurrency, the claim’s grant’s (gradeBatch passes it). With spendCurrency set, a currency-less claim and a claim in spendCurrency count each other, as the §6.1 Amount counts them; with none, a currency-less claim counts only earlier claims without one. Counted claims have claimedAt in [t − windows.spendWindowDays, t] (days are fixed 24 h) and come strictly before the claim in (claimedAt ms, id) order; a claim id listed twice counts once; the claim’s own id never counts, whatever copy holds it (one claim read at 10:00:10 and again at 10:00:50 has one id, §10, so an earlier copy of it is never its prior spend); the sum is 0 for a type not in spendTypes. Amounts are non-negative by schema, so a refund cannot lower it. Two $30 purchases against a $50 ceiling make the second OVER_SCOPE, with or without a currency on either (Hard Rule 5: leaving the currency out never lets spend escape the cumulative ceiling).

6.4 Confidence and reasons

Confidence: VERIFIED/PARTIAL: the best score (display). NOT_FOUND: 1. OVER_SCOPE: 1. UNASKED: the attribution score. UNVERIFIABLE: 0. reasons is a list of { code: ReasonCode, params }, where ReasonCode is a closed enum in schema.ts and params are ids, ISO timestamps from the inputs, rule ids and enum values — never an address, a name, a subject or a formatted local time. The UI renders the words. This keeps receipts deterministic across time zones and locales and keeps PII out of anything shareable.

7. UNASKED (attribution.ts)

attribute(orphan, activity, policy) uses allowlisted data only:

Activity = { sessions: {agent, at}[], idle: {from, to}[], entries: {agent, at, type?}[],
             adapterSyncs: {agent, lastSyncAt}[], claims: Claim[] }

Where the activity comes from (Sprint 3). The readers of the agents’ own pages are off (Decisions.md [3.10]), so every list but idle comes from the logs the person pastes (docs/claims-import.md). sessions are the minutes the timed entries of a pasted log name, a claim or not (claims.import activeAt); entries are the pasted claims of an activity view at minute or hour precision; claims are every stored claim; idle holds the times Chrome reported the person away, only while they have turned away detection on. An agent’s adapter sync (adapterSyncs, which UNASKED finality reads) is its last import: the instant the service worker stored it, by the worker’s clock (lastImportAt.<agent> in meta, never moved back), not the instant the side panel parsed the paste, which can be minutes earlier.

Signal Fires for agent A when
agentSessionActive a session ping of A is within ±unasked.windows.sessionMin of occurredAt. A ping at the instant of one of A’s entries is that entry, one fact (v1.17): when activityLogAdjacent fires and every ping in reach sits at the instant of an entry that fires it, only the heavier of the two signals fires (activityLogAdjacent on a tie), so one pasted entry never sums two weights.
userIdle (agent-independent) one recorded idle interval covers [occurredAt − idleMin, occurredAt] (inclusive; intervals are not merged). Positive evidence only: missing data never counts as idle.
agentSignature the observation’s subjectHint contains one of unasked.signatures[A] (ASCII-only case folding, §11; no NFKC). Bodies are never stored, so footers cannot be read: a documented limit.
activityLogAdjacent an activity entry of A is within ±unasked.windows.adjacentMin, and no assignable (§4.3) claim of A of the observation’s type has its precision unit [start(p,t), end(p,t)] within ±adjacentMin of the anchor (assigned or not; a vague claim cannot suppress the signal). An entry’s type is informational and not read.

For calendar.* observations the anchor of every window above is the observation’s first sighting (when the change was first seen: its sightedAt when it has one, else its observedAt, §4.1; order.ts anchorAt), not occurredAt (the event start), and not the later instant the ledger stored it at; for every other type it is occurredAt. Each agent is scored separately: its own signals plus userIdle, capped at 1, exact in bp. The likely agent is the highest score; ties by number of fired signals (including userIdle), then the lowest agent id by UTF-16 code unit. No agent-specific signal fired → no attribution. unaskedLikely needs the score ≥ unasked.threshold (at least 1 bp, §5.3). Reasons list the attributed agent’s fired signals plus userIdle, each phrased with “likely”. Attribution.adapterLastSyncAt is the attributed agent’s latest adapter sync. An UNASKED receipt stays PENDING until that sync is ≥ anchor + beforeClaimMin + graceMin (a later claim may still explain it), or until evaluatedAt ≥ anchor + finalizeUnverifiableHours, with the same anchor as the signals (the first sighting for calendar.*, else occurredAt; anchorAt). A PENDING UNASKED that re-evaluates to anything else is deleted, unless it holds reverted overrides (§8). It is then kept as stored, still PENDING until a batch produces it again or a FINAL claim supersedes it, because the batches that ran while an override was in force had its orphan taken. No batch grades such a row, so finalizeUnverifiableHours does not finalise it either: the background keeps it, and the UI must not show it as “checking” forever (Sprint 3.6, 4.3; the measured rate is in Decisions.md). A claim that is later assigned an observation with a FINAL UNASKED receipt supersedes it (§8): FINAL UNASKED observations stay assignable.

Which observations can be UNASKED (v1.9 to v1.12). Only orphans (§3), so only what a view could vouch for. Since v1.12 no Calendar row is one: a snapshot cannot tell a change the account made from one other people made to its calendar (an invitation Google adds, an organiser’s reschedule or cancellation, a colleague’s edit on a shared calendar), so the service worker stores every Calendar row as a first sighting with its first-look marker (§3, Changes the account did not make). What follows is the rule as src/core applies it, unchanged, and as it will apply again once the account’s own changes can be told (carry-forward to sprint_2 2.8). A Calendar row is an orphan only if it is a change a view followed: what a first look found already on the calendar, or what a second account’s first look found, is never UNASKED, whatever the signals say (Hard Rule 7: Attest accuses an agent of an action only when it saw the action happen). This rests on two §3 obligations (v1.10): every first sighting a Calendar pass stores carries its first-look marker, and a Calendar pass is stored all or none. So an event already on the calendar never reads as a change, whether it began before the visible range, sat on a day hidden behind “N more”, was found by a pass that recorded no window (a calendar switched on while a deletion waited for confirmation), or was found by a pass the ledger refused (it is stored, with its marker, only when a later pass derives it again). A marker blocks every row that shares its start instant and its first sighting, so a real change that the same pass first saw, on an event starting at the very instant of one that pass found already there, is spared too: a missed UNASKED, never a false one. The anchor is unchanged: a change’s first sighting. A change Attest noticed late (its comparison base read more than the attribution window, the narrowest §7 window, before its first sighting; §3, v1.11) is stored as a first sighting with its marker, so it is never UNASKED: the signals around the moment Attest looked again may say nothing about when it was made. After a clock repair (§3, v1.11) no interval synced in the future is left to make a row a change a view followed, and no chain’s changesSince lies below the clock floor, so a row first seen “in the future” never accuses an agent either. Known limit: rows carry no account, so a view that compared nothing (a first look, or a view that did not see its range whole) synced over the same days at or after a change’s first sighting also keeps that change from being UNASKED: a missed UNASKED, never a false one. A receipt is an orphan when the receipt pass covered its own instant (§3): the user opened it and Attest parsed it, so an unclaimed purchase is attributed like any other orphan, from its occurredAt; a receipt whose pass wrote no coverage (a refused row) is not.

8. State machine (conditions.ts decides finality; state.ts applies events)

grade() sets state and finalAt using isFinalizable({outcome, rule, claim, orphan, grant, coverage, context, policy, candidateDependent, eligible?}) from conditions.ts (FinalityInput): rule is the matched rule (null for the terminal default_unverifiable), orphan the observation of a claim-less UNASKED grade (null otherwise), candidateDependent whether an OVER_SCOPE outcome rests on the assigned candidate (the rows below), and eligible, optional, the claim’s eligible coverage when the caller has already computed it. It returns {final, finalAt?, finalFrom?}, with finalAt = coverage.evaluatedAt when final, and otherwise finalFrom (v1.18): the instant from which a later grading can make the receipt FINAL, written in the offset of evaluatedAt. For VERIFIED it is end(p,t) + graceMin; for PARTIAL, NOT_FOUND, an OVER_SCOPE that rests on its candidate, and pending, the graceElapsed instant (a grading past it with every source synced decides the final grade); for no_coverage, partial_coverage and default_unverifiable, end(p,t) + finalizeUnverifiableHours; for UNASKED, the earlier of its adapter sync instant and its deadline (§7). Every regrade writes it again, so it follows the outcome.

Outcome FINAL when
VERIFIED an eligible entry of every relevant source has lastSyncAt ≥ end(p,t) + graceMin (positive evidence needs no wait for the rest of the window).
PARTIAL, NOT_FOUND graceElapsed (the whole window has had time to happen and was seen).
OVER_SCOPE from claim-only facts (type, claim amount, claim address, cumulative, observed scope) immediately.
OVER_SCOPE that depended on the assigned candidate (the outcome would not be OVER_SCOPE without that candidate’s amount, currency or address) like PARTIAL: graceElapsed, so a better match can still correct it.
UNVERIFIABLE by a rule that names targetMissing or underspecified (the shipped no_target and underspecified) immediately.
UNVERIFIABLE for a type with no sources (message.sent, other; with the shipped rules, no_coverage) immediately: nothing can ever cover it.
pending, no_coverage, partial_coverage, default_unverifiable evaluatedAt ≥ end(p,t) + finalizeUnverifiableHours; a pending outcome then finalises as UNVERIFIABLE (ruleId pending, reason coverage.never_completed), so nothing stays “checking” forever and nothing silently drops out of the counts.
UNASKED §7.

Receipt invariants (enforced by the schema): override is present iff state is USER_OVERRIDDEN; finalAt is present iff the state before any override is FINAL, and finalFrom never then (v1.18; grade() writes it on every receipt it does not finalise, and a PENDING row stored before v1.18 may lack it); grade is UNASKED iff attribution is likely and claimId is absent; attribution and supersededBy appear only on UNASKED receipts; an override reason contains a visible character; override.at ≥ computedAt; override.user {grade: override.grade} is present iff override is, and superseded {claimId: supersededBy} iff supersededBy is, both after every graded reason in §15 order. The schema also requires Claim.id = idFromHash(rawHash) beside Observation.id = idFromHash(contentHash). observationIds = [the assigned candidate] when one is assigned (score ≥ thresholds.partial), else []; an UNASKED receipt’s is [its orphan]; a scope-diff receipt’s is []. grade’s best candidate is the highest exact score, ties broken by observation id, so match.best names the assigned candidate whenever one is assigned, and names the best candidate even below partial. A claim-less grade whose matched rule concludes anything but UNASKED or OVER_SCOPE returns rule default_unverifiable, FINAL, subject {k:'unasked', observationId: candidates[0]} (or {k:'scope', …} for a scope subject), observationIds []; gradeBatch never returns it. A claim-less grade whose only orphan has observedAt > evaluatedAt throws GradeError('claimless_subject'). On an UNASKED receipt, an override VERIFIED means the user confirms an agent did it; NOT_FOUND means the user says no agent did. An override from PENDING freezes the computed part as it was; overridden receipts are counted by their override grade. Receipt signals keys are the §5.1 feature names plus score. reverted is absent, or holds 1 to SCHEMA_LIMITS.REASONS entries, oldest first. Each entry is {grade, at, revertedAt, previousState, computedAt, observationIds}, never the override’s reason. Rules: computedAt ≤ at ≤ revertedAt. Each entry’s at and computedAt are no earlier than the previous entry’s revertedAt and computedAt. A regrade between two entries happens only once the earlier one no longer held the subject. override.at is no earlier than the last revertedAt. An entry’s computedAt is no later than the receipt’s. While they are equal, its previousState is the state before any override and its observationIds are the receipt’s. Once regraded, the entry was PENDING, and the receipt was not computed while the override held: a claim’s computedAt > revertedAt, an orphan’s computedAt ∉ (at, revertedAt]. A claim-less receipt’s entries hold its own observationIds.

state.ts applies events to stored receipts and never decides finality:

Receipt state machine. grade() creates a receipt as PENDING or FINAL, as isFinalizable decides. regrade replaces a PENDING receipt with grade()'s next receipt, PENDING or FINAL. override moves PENDING or FINAL to USER_OVERRIDDEN and stores previousState; revert(at) returns to previousState and keeps the override in reverted. supersede applies to UNASKED receipts in any state: the state is unchanged and supersededBy is set. Any other pair throws IllegalTransitionError.

The diagram (docs/assets/receipt-states.svg) restates the rules above; where the two differ, the text wins. Every (state, event) pair, legal or not, is a row of the state table test in extension/tests/unit/core/state.spec.ts. Golden scenarios show the events on graded receipts: 56 (override beside a computed NOT_FOUND), 57 (an UNASKED receipt superseded by a later claim), and 44 and 66 (receipts that become FINAL only by finalizeUnverifiableHours).

9. Replay (replay.ts)

LedgerSnapshot = { claims, observations, receipts, receiptInputs, policies }   // receiptInputs keyed by inputsHash: {coverage, grant, context, policyHash}, each coverage joined from batchCoverages
replay(snapshot) → Promise<ReplayReport>

Replay groups receipts by computedAt and processes the groups in order. For each group it rebuilds candidates and the assignment from claims and observations as they were at that instant (observedAt ≤ computedAt), with the pre-assignments in force at that instant fixed (§4.3: preAssignments of the stored receipts, the rule gradeBatch applied), takes coverage, grant and context from receiptInputs (they cannot be rebuilt from current state), runs grade, and compares every computed field with the stored receipt: id, inputsHash, grade, ruleId, state, then agent, claimId, observationIds, requestId, confidence, reasons, signals, policyHash, attribution, computedAt, finalAt and finalFrom (COMPARED_FIELDS in replay-compare.ts). An UNASKED receipt’s observation must be an orphan of the rebuilt batch by §3’s rule (the same isOrphanCandidate, under the receipt’s stored coverage), assigned to no claim and not taken; otherwise the mismatch is orphan. replay throws PolicyError('policy_not_loaded'), before replaying anything, when policies holds an object loadPolicy did not return. The lifecycle part (override, reverted, supersededBy and their reasons) records events, not grading, and is set aside. Each observation a receipt is graded with is also checked against its contentHash (§10), so evidence edited in place is reported (contentHash). Overridden receipts are compared on their previousState. An override releases or takes an observation only in batches evaluated strictly after its at, and a claim overridden from PENDING keeps its stored evidence fixed after its last grading (§4.3). A reverted override stays in force up to its revertedAt, and a claim it held is not in the batches it held the claim out of (batch-fixed.ts heldOutAt), so a revert never makes a stored receipt unreproducible. The report lists every mismatch. The ledger must therefore store receiptInputs (Sprint 2.6/3.6), all of it derived data with no content (Hard Rule 2), keep several coverage intervals per source within retention, and delete a receiptInputs row when its receipt is regraded. Replay covers the receipts inside the retention window; the 90-day purge removes receipts together with their evidence and inputs.

Replay procedure. To prove the receipts in a ledger:

  1. Write the ledger to a JSON file in the replay format. It is the ledger export (extension/src/ledger/export.ts, format attest-ledger: the requests, claims, observations, grants, receipts, adapter health, coverage and meta tables, each parsed by its schema) plus the three maps replay needs: receiptInputs, keyed by each receipt’s inputsHash and naming its batch’s coverage by coverageHash; batchCoverages, each batch’s coverage once, keyed by that hash (schema version 7: every receipt of a batch was graded with the same coverage); and policies, the policy documents keyed by the policyHash each receipt names. extension/tests/tools/attest-snapshot.ts documents the format, validates every object with its strict schema, checks that each coverage and each policy hashes to its own key, and joins each receipt’s inputs with its coverage. The extension’s own export writes all three maps (since Sprint 3.13, and batchCoverages since schema version 7).
  2. From the repo root, run pnpm attest replay <export.json>. The root attest script runs the extension’s attest script, tests/tools/attest-cli.ts under tsx, which builds the LedgerSnapshot above from the file and calls replay(snapshot) (extension/src/core/replay.ts). A relative path is read from the directory pnpm was run in.
  3. Read the summary: the receipts and batches replayed, how many were reproduced, how many mismatched, the mismatches by field, and the first mismatches (up to LISTED_MISMATCHES in attest-cli.ts), each as receipt id, field, stored value and replayed value. A field is one of the compared fields above when the two values differ (signals and reasons are shown as SHA-256 hashes), or says why the receipt could not be graded again (receiptInputs, policy, subject, orphan, contentHash, error); ReplayReport and REPLAY_FIELDS in replay.ts have the exact shape. The output holds ids, hashes, grades, rule ids and states, never content. Text from the export (a key in an error path) is written JSON-quoted, with every control, format, separator, private-use or unassigned character escaped as \u{…} (tests/tools/printable.ts), so a crafted export cannot drive the terminal. The file name the CLI echoes (the summary’s first line, cannot read …, … is not JSON) and every reported value are escaped the same way, line breaks included, so a file name cannot start an output line of its own. Exit status 0 means every receipt in the file was reproduced, 1 means at least one mismatch, and 2 means bad arguments or a file that does not parse.

A mismatch means a stored receipt cannot be re-derived from its stored inputs: the ledger was changed outside gradeBatch and state.ts, or src/core changed what it decides without a policy or contract change. Either one breaks Hard Rule 4. extension/tests/unit/tools/attest-cli.spec.ts runs the command on synthetic ledgers, and on copies with a changed stored grade, a policy filed under the wrong hash and a file that is not JSON.

10. Ids and hashes (hash.ts)

11. Normalisation (normalize.ts)

12. Policy keys (version 2)

Every number above comes from policy/default.json (Hard Rule 4; a test fails the build if a policy value appears as a literal in code). Values marked chosen were left open by the spec; each has a Decisions entry. allowlistedFields[source] lists the content fields a raw row may supply (counterparty, merchant, amountCents, currency, subjectHint, occurredAt) plus the derived markers rowKeyHash and contentHash; occurredAt is required in every entry, and id, source, type and observedAt are never allowed (redact.ts computes them). What each allowlisted field holds, per source, is in docs/privacy-architecture.md §4.

{
  "version": 2,
  "windows": { "beforeClaimMin": 2, "afterClaimMin": 45, "graceMin": 30, "calendarDays": 3,
               "toleranceMin": 1, "spendWindowDays": 30, "finalizeUnverifiableHours": 24 },   // last three chosen
  "thresholds": { "verified": 0.85, "partial": 0.5 },
  "features": { "timeProximity": 0.25, "counterpartyExact": 0.35, "counterpartyDomain": 0.15,
                "counterpartyName": 0.25, "subjectOverlap": 0.15, "amountExact": 0.25,
                "merchantMatch": 0.2, "modelScore": 0.15 },                 // counterpartyName chosen; modelScore v1.19
  "sourcesByType": {
    "email.sent": ["gmail.sent"],
    "calendar.created": ["gcal.event"], "calendar.updated": ["gcal.event"], "calendar.deleted": ["gcal.event"],
    "purchase.made": ["gmail.receipt"], "subscription.changed": ["gmail.receipt"], "listing.posted": ["gmail.receipt"],
    "message.sent": [], "other": []
  },
  "featuresByType": {
    "email.sent":           ["timeProximity", "counterpartyExact", "counterpartyDomain", "counterpartyName", "subjectOverlap"],
    "calendar.created":     ["timeProximity", "subjectOverlap", "counterpartyExact", "counterpartyDomain", "counterpartyName"],
    "calendar.updated":     ["timeProximity", "subjectOverlap", "counterpartyExact", "counterpartyDomain", "counterpartyName"],
    "calendar.deleted":     ["timeProximity", "subjectOverlap", "counterpartyExact", "counterpartyDomain", "counterpartyName"],
    "purchase.made":        ["timeProximity", "amountExact", "merchantMatch", "subjectOverlap"],
    "subscription.changed": ["timeProximity", "amountExact", "merchantMatch", "subjectOverlap"],
    "listing.posted":       ["timeProximity", "amountExact", "merchantMatch", "subjectOverlap"],
    "message.sent": [], "other": []
  },
  "identityFeatures": {
    "email.sent": ["counterpartyExact", "counterpartyName"],
    "calendar.created": ["subjectOverlap", "counterpartyExact", "counterpartyName"],
    "calendar.updated": ["subjectOverlap", "counterpartyExact", "counterpartyName"],
    "calendar.deleted": ["subjectOverlap", "counterpartyExact", "counterpartyName"],
    "purchase.made": ["amountExact", "merchantMatch"], "subscription.changed": ["amountExact", "merchantMatch"],
    "listing.posted": ["amountExact", "merchantMatch"], "message.sent": [], "other": []
  },
  "allowlistTypes": ["email.sent", "calendar.created", "calendar.updated", "calendar.deleted"],
  "spendTypes": ["purchase.made", "subscription.changed"],
  "commonDomains": ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com", "yahoo.com",
                    "icloud.com", "me.com", "aol.com", "proton.me", "protonmail.com"],                 // chosen
  "receiptMerchants": [],                       // normalised merchant names, one per shipped receipt parser (Sprint 2.3; a test pins it to the parser list); empty = no purchase is covered yet
  "normalize": { "stopWords": ["a", "an", "the", "and", "or", "of", "to", "for", "on", "in", "at", "with", "from", "by", "is", "it", "this", "that", "your", "my", "our"] },   // chosen
  "rules": [
    { "id": "unasked",                 "when": { "unaskedLikely": true },                                         "then": "UNASKED" },
    { "id": "over_scope_observed",     "when": { "observedScopeExceedsDeclared": true },                          "then": "OVER_SCOPE" },
    { "id": "over_scope",              "when": { "typeNotInGrant": true },                                        "then": "OVER_SCOPE" },
    { "id": "over_allowlist",          "when": { "counterpartyNotAllowed": true },                                "then": "OVER_SCOPE" },
    { "id": "over_ceiling",            "when": { "amountAboveCeiling": true },                                    "then": "OVER_SCOPE" },
    { "id": "over_ceiling_cumulative", "when": { "cumulativeAboveCeiling": true },                                "then": "OVER_SCOPE" },
    { "id": "no_target",               "when": { "targetMissing": true },                                         "then": "UNVERIFIABLE" },
    { "id": "underspecified",          "when": { "underspecified": true },                                        "then": "UNVERIFIABLE" },
    { "id": "no_coverage",             "when": { "coverage": "none" },                                            "then": "UNVERIFIABLE" },
    { "id": "verified",                "when": { "bestScoreGte": "verified" },                                    "then": "VERIFIED" },
    { "id": "partial",                 "when": { "bestScoreGte": "partial" },                                     "then": "PARTIAL" },
    { "id": "pending",                 "when": { "graceElapsed": false },                                         "then": "PENDING" },
    { "id": "partial_coverage",        "when": { "coverage": "partial", "bestScoreLt": "partial" },               "then": "UNVERIFIABLE" },
    { "id": "not_found",               "when": { "graceElapsed": true, "coverage": "full", "bestScoreLt": "partial" }, "then": "NOT_FOUND" }
  ],
  "unasked": {
    "signals": { "agentSessionActive": 0.4, "userIdle": 0.3, "agentSignature": 0.5, "activityLogAdjacent": 0.4 },
    "threshold": 0.6,                                                       // at least 1 bp (§5.3)
    "windows": { "sessionMin": 10, "idleMin": 2, "adjacentMin": 2 },        // sessionMin, idleMin chosen; adjacentMin per task 1.7
    "signatures": { "muse": [], "chatgpt": [], "dots": [], "claude": [] }  // empty until captured fixtures show real ones; never invented
  },
  "allowlistedFields": {
    "gmail.sent":    ["counterparty", "subjectHint", "occurredAt", "rowKeyHash", "contentHash"],
    "gmail.receipt": ["merchant", "amountCents", "currency", "subjectHint", "occurredAt", "rowKeyHash", "contentHash"],
    "gcal.event":    ["subjectHint", "occurredAt", "counterparty", "rowKeyHash", "contentHash"]
  }
}

13. Module contracts (Sprint 1)

All in src/core, all pure. Async appears only where an injected index is awaited. Dependencies point downward only (no cycles): unicode-table-*, invariant-error, units, sha256, base32, punycode → unicode-baseline, hash, normalize-time → schema → normalize, policy, ratio → window → order → redact, candidates, features → conditions, receipt → scope, attribution → grade → state, batch-fixed → batch → replay. unicode-baseline imports only the generated unicode-table-* files, and normalize-time only units; schema reads its character classes from unicode-baseline (through schema-text).

Module Exports
invariant-error.ts InvariantError with closed codes no_such_row (batch-table.ts) and write_after_digest (sha256.ts); a leaf module that imports nothing
units.ts MS_PER_MINUTE, MS_PER_HOUR, MS_PER_DAY, BASIS_POINTS, CURRENCY_MINOR_UNITS
hash.ts (+ sha256.ts, base32.ts) sha256Hex(text), canonicalJson(value), idFromHash(hex)
schema-primitives.ts, schema-enums.ts The length caps (SCHEMA_LIMITS) and branded primitives (Ulid, Sha256, IsoTs, AmountCents, Currency, RuleId, ComponentId), and the closed sets (AgentId, ActionType, ObservationSource, ClaimPrecision, SourceKind, Grade, OverrideGrade, ReceiptState, UnaskedSignal, ReasonCode, MatchFeature, ReceiptSignal): each imports nothing from schema.ts, which re-exports them, so every caller still imports from schema.ts
schema.ts Branded Ulid, Sha256, IsoTs; AgentId, ActionType, ObservationSource, ClaimPrecision, Request, Claim (§7 + targetAt?, claimedAtPrecision, claimedAtEnd?), ReasonParams, Observation (§7 + rowKeyHash?, sightedAt?: set only by the ledger, strictly before observedAt, §10), Grant (§7 + spendCurrency? (required when spendCeilingCents is set), accounts?), Grade, ReceiptState, ReasonCode, Reason, Receipt (§7 + agent, policyHash, attribution?: 'likely', override?, reverted?, supersededBy?, reasons: Reason[]), ObserverCoverage (changesSince?: gcal.event only, never after lastSyncAt, §3), Coverage, Attribution (with adapterLastSyncAt), GradeContext, Activity, HealthReport, SCHEMA_LIMITS (amountCents ≥ 0, ≤ 10¹²). src/ledger/types.ts re-exports these. counterparty, merchant and subjectHint (Claim and Observation) are one line: no \p{Cc} (tab, CR, LF, NUL, U+0085, …), U+2028 or U+2029 (schema-text.ts: ONE_LINE, oneLineText); invisible format marks (\p{Cf}) are allowed. Adapter, observer and extractor ids and versions (HealthReport.adapterId/version, Claim.extractor.id/version, ObserverCoverage.observerId, and on the bus adapter.claims adapterId/version, observer.observations observerId/version, coverage.ping observerId) are ComponentId tokens (schema-text.ts COMPONENT_ID: an ASCII letter or digit, then letters, digits, ., _, + or -; 1 to SCHEMA_LIMITS.ID characters). Each HealthReport.reasons item is a reason code (HEALTH_REASON: lowercase snake_case segments joined by dots, at most SCHEMA_LIMITS.ID characters, e.g. selector.missing), never text. Request.conversationId is a vendor id (schema-text.ts VENDOR_ID, vendorId: empty, or an ASCII letter or digit then letters, digits, ., _, :, + or -; at most SCHEMA_LIMITS.CONVERSATION_ID characters), never a title or a line of chat. Grant.counterpartyAllowlist entries, Grant.declaredScopes and Grant.observedScopes items, and GradeContext.scope.observed are one line (oneLineText).
normalize.ts (+ punycode.ts, unicode-baseline.ts) email, sameAddress, name, nameTokens, amount, subjectTokens, stopWordKey, instantMs, isoWithOffset, resolveRelative → {start, end, precision}
policy.ts loadPolicy → frozen Policy with basis-point weights/thresholds and policyHash (throws PolicyError('invalid_policy') with one “path: problem” line per issue, including a rule without its evidence, §6, and timeProximity in an identityFeatures list, §5.2 v1.11); assertLoadedPolicy(policy) throws PolicyError('policy_not_loaded') for any object loadPolicy did not return; isLoadedRules(rules) is true only for the rules array of a policy loadPolicy returned (conditions.ts firstMatch keeps its per-rule memo only for such an array)
redact.ts project(source, raw, { type, observedAt, rowKey? }, policy) → Observation (reads only own data properties; an allowlisted field held by an accessor (getter or setter) or inherited from a prototype, occurredAt included, refuses the whole row with RedactError('invalid_field') and is never invoked or read; fields the allowlist does not name are never looked at; an allowlisted own value other than occurredAt that fails its Observation field schema refuses the whole row with RedactError('invalid_field') and is never dropped, cut or coerced (only subjectHint is first cut to 80 code points); otherwise throws RedactError('missing_occurred_at') or RedactError('invalid_argument')); contentHashOf(observation) (the one §10 content hash: project, the ledger and replay call it); observationIntegrityProblems(observation, policy) → [field, message][] (each content field its source’s allowlist does not name, a sightedAt, which only the ledger sets, and a contentHash that is not contentHashOf its content; rowKeyHash is not checked against the allowlist, because project keeps it whenever there is a row id); hashText(text)
candidates.ts ObservationIndex, createMemoryIndex, candidates, assign(pairs, policy, fixed, taken), exclusiveCandidates
ratio.ts Ratio ({num, den} as bigint), ratio, compareRatio, ratioGteBasisPoints, weightedScore
window.ts precisionBounds, claimWindow, inClaimWindow, precisionStartMs, writtenOffsetMinutes (throws WindowError('no_offset') for text without a trailing offset)
order.ts (internal) compareText, compareNumbers, firstSeenAt, firstSeenMs, representative, compareCandidates, mergeIntervals, fromRelevantSource, hasFirstSeenRule, firstSeenFromMs, passesFirstSeen, anchorAt: the §3/§4.1/§7 rules candidates.ts, batch-retrieve.ts, batch-orphans.ts, grade-guard.ts, attribution.ts and conditions-finality.ts share (firstSeenAt is also the ledger’s reading of a stored row’s first sighting)
features.ts applicableFeatures, isUnderspecified, isAssignable, features → { score: Ratio, signals, reasonCodes } (assignable claims only; 0 for a pair that matches on time alone, the §5.3 content gate, v1.11), pairScorer, RowScorer (the batch path, with the same gate)
conditions.ts coverageLevel(claim, grant, coverage, policy), graceElapsed(claim, grant, coverage, policy), calendarBound(claim, policy, repairs?) (§6.1 v1.10, v1.11, v1.16: what a calendar claim asks of each gcal.event entry, {syncMs: end(p,t) + graceMin, change, repaired}, change true for a calendar.updated or calendar.deleted claim, whose every entry is capped coverage.change_unprovable, repaired true for a calendar.created claim whose first-seen bound lies in one of the coverage’s repairs, whose every entry is capped coverage.clock_repaired; null for every other claim; v1.11 removed changeSpan), condition evaluators (the coverage level in the internal conditions-level.ts, with the per-account rule, §6.1 v1.11), isFinalizable({outcome, rule, claim, orphan, grant, coverage, context, policy, candidateDependent, eligible?}) → {final, finalAt?, finalFrom?} (FinalityInput, §8, v1.18)
receipt.ts receiptId(subject), inputsHash(inputs), receipt builder
scope.ts priorSpendCents(claim, claims, policy, spendCurrency?), spendIndex(claims, policy) → (claim, spendCurrency?) => number, scopeSubjects(grant)
attribution.ts attribute(observation, activity, policy, evaluatedAt?) → Attribution \| null; attributor(activity, policy, evaluatedAt?) → reusable per batch; refuses a policy loadPolicy did not return with PolicyError('policy_not_loaded'); the activity it reads is indexed in the internal attribution-activity.ts, the one-fact rule (v1.17) included
reason-text.ts reasonText(reason) → one English sentence per ReasonCode (UI rendering; every unasked.* text says “likely”); depends only on schema; throws ReasonTextError('unknown_code') with no detail (an unparsed code is never repeated, Hard Rule 10) or ReasonTextError('invalid_params', code or code.param)
grade.ts (+ grade-guard.ts) grade(claim \| null, candidates, grant, policy, coverage, context); throws GradeError (foreign_candidate, claimless_subject, agent_mismatch, unasked_with_claim, and unknown_reason_code for a schema older than the contract) and PolicyError('policy_not_loaded') (grade, gradeOrphan, gradeClaim)
model-raise.ts raisedBest(best, context, policy): the best candidate, chosen and assigned on its floor score, with that score raised by context.modelScores as §5.4 allows (v1.19), or as it was
state.ts transition(receipt, event) for regrade, override, revert (with its instant), supersede; IllegalTransitionError (codes include regrade_held)
batch.ts gradeBatch(claims, index, grants, policy, coverage, activity, fixed, modelScores) → Promise<{receipt, inputs}[]>; modelScores maps a claim id to that claim’s model scores, which its context carries (§5.4; Sprint 5.4), and a claim with no entry is graded rules-only; fixed lists {observationId, claimId, after, until?, released?} (claimId: null for a taken observation), of which gradeBatch fixes those in force at evaluatedAt. It throws BatchError('claim_fixed') for a graded claim named by one that has not ended, and BatchError('stale_evaluated_at') for a claim pair from a grading at or after evaluatedAt. preAssignments(receipts), inForceAt, fixedInForce, heldOutAt and gradedAt (batch-fixed.ts, with batch-fixed-claims.ts) derive them for the background and for replay.
replay.ts LedgerSnapshot, ReplayReport, replay(snapshot) (throws PolicyError('policy_not_loaded') for an unloaded snapshot policy)

14. Deviations from build_spec §7 (all in Decisions.md, Sprint 1)

  1. grade() takes a sixth argument, context (cumulative spend, attribution, scope), required and hashed (Hard Rule 4 needs every input in the hash).
  2. Receipt ids derive from the subject (claim, orphan observation, scope), not from inputsHash; inputsHash is stored. Idempotency is kept: same subject → same row.
  3. Receipt gains agent, policyHash, attribution, override, reverted, and structured reasons. Claim gains targetAt and claimedAtPrecision. Observation gains rowKeyHash. Grant gains spendCurrency and accounts.
  4. The policy gains the keys in §12, the over-scope rules move before no_coverage, and not_found requires full coverage.
  5. Finality is per outcome (§8): VERIFIED after a sync at end(p,t) + graceMin; PARTIAL and NOT_FOUND only after the whole window was seen; coverage-based UNVERIFIABLE after finalizeUnverifiableHours.
  6. USER_OVERRIDDEN can be reverted (Sprint 4.3). A revert carries its instant and keeps the override in reverted, so a revert never erases what a batch saw (§8).
  7. src/core gains units.ts, hash.ts, sha256.ts, base32.ts, punycode.ts, ratio.ts, window.ts, conditions.ts, receipt.ts, batch.ts beside the §6 file list; the no-literal test exempts sha256.ts, base32.ts, punycode.ts, units.ts and schema.ts with a reason each.
  8. Text is read by a pinned Unicode baseline, 15.0.0, not by the engine’s own Unicode version (SEC-1, Hard Rule 4). src/core gains unicode-baseline.ts (asBaseline, onBaseline, trimBaseline, lowerBaseline, BASELINE_CLASS) and the generated unicode-table-assigned.ts, unicode-table-classes.ts and unicode-table-case.ts. These are written by scripts/gen-unicode-baseline.ts from the UCD files committed in scripts/ucd/15.0.0/ (DerivedAge, DerivedGeneralCategory, PropList, WordBreakProperty; SHA-256 checked). tests/unit/core/unicode-tables.spec.ts fails when a table is stale or hand-edited, and tests/unit/core/unicode-fence.spec.ts walks the syntax tree of every file in src/core and its subdirectories (any file there other than a .ts source and the top-level tsconfig.json fails). It refuses engine \p{…}, \s, trim(), case mapping and Unicode case folding elsewhere: an i flag with u or v, an i pattern that is not plain ASCII or holds a backreference, and any inline modifier group. It also refuses any regular expression built from text only run time knows (controls in tests/unit/core/unicode-fence-controls.spec.ts and tests/unit/core/unicode-fence-pins.spec.ts). Like the purity fences (item 9), it catches engine Unicode reached by accident or through ordinary idioms. Deliberately evasive code, such as a method name composed from fragments at run time to dodge every check, is a code-review matter.
  9. The purity fences on src/core (Hard Rule 4) are three: lint (eslint-rules/core-fences.js, attest/core-determinism and attest/core-globals, with type information), the grep test (tests/unit/core/core-purity.spec.ts), and a runtime guard (tests/unit/core/core-poison.ts) installed before any src/core module loads. The typecheck backs them: src/core/tsconfig.json loads only the ES2023 lib, so a host name (Image, document) fails it. The globals they refuse come from one file, eslint-rules/core-names.js, so they cannot drift. Lint and the grep test hold every global src/core reads to an allowlist: the ES2023 built-ins less the restricted ones and eval, Function, Proxy, escape and unescape. A host name no list foresaw (Image, clientInformation, open) therefore fails unless the file declares or imports it. RegExp may be read only as new RegExp(…) or x instanceof RegExp, because its legacy statics (RegExp.lastMatch, RegExp.$1) hold the last match any code made. The guard poisons the names lint gives src/core’s no-restricted-globals and attest/no-network, which include clientInformation (Chrome’s other name for navigator) and a window’s loaders and ways out (Image, Audio, FontFace, Worker, SharedWorker, Notification, BroadcastChannel, open, opener, parent, top, frames, postMessage, navigation). Lint and the grep test refuse, in src/core, the comments that would switch the typecheck off: @ts-expect-error, @ts-ignore, @ts-nocheck and triple-slash references. The grep test finds a read of a global in the syntax tree, so a local of the same name (src/core calls its time windows window) is not the global. pnpm test:poison runs every core, golden and property test under the guard and fails on any impure call, any changed built-in, or less than 95% of src/core’s lines run. The fences catch impurity written by accident or through ordinary idioms. Deliberately evasive code is a code-review matter. These forms still pass lint and the grep test: (a) a key built at run time and held in a plain name, read by reflection from a value whose type does not show a Date: const k = ['getTimezone', 'Offset'].join(''); Reflect.get(Object.getPrototypeOf(d as object), k); the guard catches the call when a test runs it. (b) A Date passed through a type that hides it, then made text: (v: { toString(): string }) => v.toString(); the guard catches it when run. (c) A built-in changed through a key built at run time on a prototype reached by reflection: (Object.getPrototypeOf('') as Record<string, unknown>)[k] = f; the poisoned run reports the changed built-in after the test that ran it. (d) Code that stays pure only while it detects the guard, for example by testing Date.UTC.name (the guard wraps Date.UTC, so under it the name is not 'UTC'); no runtime check can rule this out. (e) An impure path that no core, golden or property test runs; the guard sees only code that runs, and the 95% floor limits how much code that can be, not which. (f) A stacked type assertion through a function call or more than five containers: identity(x as unknown) as string. (g) For the grep test only, forms that need types: String(date), ${date}, a key built from fragments; typed lint and the guard catch these. (h) For the guard only: it poisons globals by name, so a host global on no list (Option, URL, structuredClone) and RegExp’s legacy statics pass it; lint and the grep test refuse both, and the typecheck refuses the host global. Two allowances of the guard are deliberate: (i) zod’s own reads of globalThis (its settings) and navigator (a probe that picks its compiled or its plain parser) pass, because neither changes what zod returns; the same read from any other file is caught. (j) The poisoned run poisons chrome and browser before the first test rather than before src/core loads, because the test runner stubs them after the guard is installed, and it leaves globalThis itself as it is, because Vitest’s expect reads it on every assertion: checking each read made one property file three times slower and timed it out. Every other banned global is still poisoned on the global object, lint and the grep test ban all three names, and core-poison.spec.ts poisons all three.
  10. An observer’s health is OK, IDLE or DEGRADED (build_spec §7’s adapter contract has OK DEGRADED): IDLE is a page the observer does not read (another Gmail folder, no conversation open), neither OK nor broken, and it never replaces the status another tab’s page gave (sprint_2 2.11, Decisions [2.11]). It changes no grading input: health is never read by src/core.

Every change to src/core is also read by the reviews that close each sprint: the security review covers every file the sprint touched (agents/security_reviewer.md), and the tech review checks src/core for clocks, randomness and impure imports (agents/tech_reviewer.md). Those reviews, not the fences of items 8 and 9, are the check on deliberately evasive code.

15. Golden scenarios (tests/golden/)

Each scenario is one JSON file, tests/golden/<nn>-<slug>.json (two digits, three from 100 on), written from this document alone — never from the code. This section is the whole file format: scenario-schema.ts is its strict Zod schema (an unknown key fails at every level), and pnpm check-docs fails when a key that schema accepts is missing from the examples below. tests/golden/README.md explains how the files are checked and how to compute an expectation by hand; it adds no format rule. Hand-written files use short refs (c1, o2, g1); the loader (tests/golden/load.ts) builds full, schema-valid objects from them, fills documented defaults, and computes every hash and id with hash.ts. Ids and hashes are never typed by hand; a generated snapshot (tests/golden/__snapshots__/ids.json, written by a script and reviewed) pins their stability.

A minimal file (golden 01):

{
  "name": "exact match, same minute",
  "covers": ["ledger_engineer:exact_match"],          // which mandatory scenario(s) or contract rule it proves
  "mode": "batch",                                     // "batch" runs gradeBatch; "grade" calls grade() directly
  "policyPatch": { },                                  // optional JSON merge patch on policy/default.json, validated by loadPolicy
  "input": {
    "evaluatedAt": "2026-10-06T15:00:00-04:00",
    "claims":       [{ "ref": "c1", "agent": "muse", "type": "email.sent", "counterparty": "priya@company.com",
                       "subjectHint": "Q3 deck", "claimedAt": "2026-10-06T14:02:00-04:00" }],
    "observations": [{ "ref": "o1", "source": "gmail.sent", "type": "email.sent", "counterparty": "priya@company.com",
                       "subjectHint": "Q3 deck", "occurredAt": "2026-10-06T14:02:00-04:00" }],
    "grants":       [{ "agent": "muse" }],              // defaults: all types allowed, no allowlist, no ceiling, no accounts
    "coverage":     [{ "source": "gmail.sent", "lastSyncAt": "…", "windowStart": "…", "windowEnd": "…", "accountHash": "acct-a" }],
    "activity":     { },                                // optional, §7 shape with refs
    "fixed":        [ ]                                 // optional [{ "claim": "c9", "observation": "o9" }]
  },
  "expect": [{ "ref": "c1", "grade": "VERIFIED", "ruleId": "verified", "state": "FINAL",
               "observationRefs": ["o1"], "score": "1/1", "reasonCodes": ["match.best"] }]
}

Every key a file may hold. This is a catalogue, not one valid file: input.grade belongs to grade mode, while fixed, regrade events and expect[].inputs belong to batch mode; an observation holds only its source’s fields (counterparty from gmail.sent or gcal.event; merchant, amountCents and currency from gmail.receipt, §12), and allCalendars and changesSince describe a gcal.event entry.

{
  "name": "…",                                         // at most 120 characters
  "covers": ["ledger_engineer:exact_match", "edge:…", "contract:…"],
  "mode": "batch",                                     // or "grade"
  "policyPatch": { },
  "notes": ["the hand computation, one step per line"],   // or one string
  "input": {
    "evaluatedAt": "…",
    "claims": [{ "ref": "c1", "agent": "muse", "type": "purchase.made", "counterparty": "…", "amountCents": 4999,
                 "currency": "USD", "subjectHint": "…", "claimedAt": "…", "claimedAtPrecision": "day",
                 "claimedAtEnd": "…", "targetAt": "…", "observedAt": "…", "sourceKind": "activity_view",
                 "text": "…", "rowKey": "label" }],
    "observations": [{ "ref": "o1", "source": "gmail.receipt", "type": "purchase.made", "counterparty": "…",
                       "merchant": "…", "amountCents": 4999, "currency": "USD", "subjectHint": "…",
                       "occurredAt": "…", "observedAt": "…", "rowKey": "label" }],
    "grants": [{ "ref": "g1", "agent": "muse", "allowedTypes": ["purchase.made"], "counterpartyAllowlist": ["…"],
                 "spendCeilingCents": 5000, "spendCurrency": "USD", "declaredScopes": ["…"], "observedScopes": ["…"],
                 "accounts": { "gmail.receipt": ["acct-a"] }, "updatedAt": "…" }],
    "coverage": [{ "observerId": "…", "source": "gmail.receipt", "lastSyncAt": "…", "windowStart": "…",
                   "windowEnd": "…", "accountHash": "acct-a", "allCalendars": true, "changesSince": "…" }],
    "repairs": [{ "from": "…", "to": "…" }],
    "activity": { "sessions": [{ "agent": "muse", "at": "…" }], "idle": [{ "from": "…", "to": "…" }],
                  "entries": [{ "agent": "muse", "at": "…", "type": "email.sent" }],
                  "adapterSyncs": [{ "agent": "muse", "lastSyncAt": "…" }], "claims": ["c1"] },
    "fixed": [{ "claim": "c9", "observation": "o9" }, { "claim": null, "observation": "o3" }],
    "modelScores": [{ "claim": "c1", "scores": [{ "observation": "o1", "score": 10000 }] }],
    "grade": { "claim": null, "candidates": ["o1"], "agent": "muse", "priorSpendCents": 0, "scope": "…" }
  },
  "events": [{ "event": "override", "ref": "c1", "grade": "VERIFIED", "reason": "…", "at": "…" },
             { "event": "supersede", "ref": "o1", "claim": "c1" },
             { "event": "regrade", "ref": "c1", "evaluatedAt": "…" },
             { "event": "regrade", "ref": "o2", "evaluatedAt": "…", "deleted": true },
             { "event": "override", "ref": "c1", "grade": "NOT_FOUND", "reason": "…", "at": "…", "rejected": true }],
  "assertIds": { "equal": [["o1", "o2"]], "ascending": [["c3", "c4"]] },
  "expect": [{ "ref": "c1", "grade": "VERIFIED", "ruleId": "verified", "state": "FINAL", "observationRefs": ["o1"],
               "score": "1/1", "raisedScore": "1/1", "reasonCodes": ["match.best"],
               "reasonParams": { "match.best": { "observationId": "o1" } },
               "agent": "muse", "inputs": { "grant": "g1", "priorSpendCents": 0, "scope": "…" } },
             { "ref": "c2", …, "state": "USER_OVERRIDDEN", "override": "NOT_FOUND", "previousState": "FINAL" },
             { "ref": "o1", "grade": "UNASKED", …, "attributionScore": "7/10", "supersededBy": "c1" },
             { "ref": "c3", "grade": "UNVERIFIABLE", "ruleId": "pending", "state": "PENDING", "finalFrom": "…", … },
             { "scope": { "agent": "muse", "scope": "gmail.read" }, "grade": "OVER_SCOPE", … }]
}

ReasonCode (closed enum in schema.ts; params are ids, ISO timestamps from the inputs, numbers, enum values):

Code When
match.best a best candidate exists (params: observationId)
match.none no candidate was scored
cp.plus_tag, cp.confusable, cp.non_ascii §5.1
claim.too_vague rule underspecified
claim.no_target rule no_target
coverage.none, coverage.partial rules no_coverage, partial_coverage (params: source)
coverage.account_unbound NOT_FOUND graded without grant.accounts for the source
coverage.calendars_hidden, coverage.changes_unfollowed, coverage.change_unprovable, coverage.clock_repaired, coverage.list_unsettled, coverage.account_partial, coverage.multiple_accounts, coverage.no_account, coverage.no_parser why coverage was capped at partial (changes_unfollowed: a calendar.created claim’s view was last synced before end(p,t) + graceMin; change_unprovable: every view of a calendar.updated or calendar.deleted claim, which is never NOT_FOUND, v1.11; clock_repaired: every view of a calendar.created claim whose first-seen bound lies in a clock repair’s span, v1.16; list_unsettled: a gmail.sent view was last synced before the claim’s grace sync, v1.14; a too-early view is named only for a span no later view saw; account_partial: only several bound accounts’ views together contain the window, v1.11; §6.1)
coverage.never_completed a pending outcome finalised after finalizeUnverifiableHours
grace.waiting rule pending (params: source)
scope.type_not_allowed rule over_scope (params: type)
scope.counterparty_not_allowed rule over_allowlist
allowlist.unchecked allowlist not checkable for a name-only claim
ceiling.exceeded rule over_ceiling (params: amountCents, ceilingCents)
ceiling.cumulative_exceeded rule over_ceiling_cumulative (params: priorSpendCents, amountCents, ceilingCents)
ceiling.currency_mismatch §6.1
scope.observed_not_declared rule over_scope_observed (params: scopeHash, never the scope text)
unasked.session, unasked.idle, unasked.signature, unasked.adjacent each fired attribution signal (always rendered with “likely”)
override.user USER_OVERRIDDEN (params: grade)
superseded an UNASKED receipt superseded by a claim (params: claimId)
Params per code (closed). match.best {observationId: Ulid}; coverage.none, coverage.partial, grace.waiting, coverage.account_unbound, coverage.no_account, coverage.multiple_accounts, coverage.calendars_hidden, coverage.changes_unfollowed, coverage.change_unprovable, coverage.clock_repaired, coverage.list_unsettled, coverage.account_partial, coverage.no_parser {source: ObservationSource} (omitted for a type with no sources); numeric params are non-negative integers (-0 is written 0); scope.type_not_allowed {type: ActionType}; ceiling.exceeded {amountCents, ceilingCents: int}; ceiling.cumulative_exceeded {priorSpendCents, amountCents, ceilingCents: int}; ceiling.currency_mismatch {currency: the claim’s currency when present and ≠ spendCurrency, else the assigned candidate’s; spendCurrency: currency code}; ceiling.exceeded.amountCents is the §6.1 Amount; scope.observed_not_declared {scopeHash: sha256Hex(scope), the observed scope exactly as stored}; unasked.* {agent: AgentId}; override.user {grade: the override’s grade, VERIFIED NOT_FOUND}; superseded {claimId: Ulid}; every other code has no params. The schema enforces this table, so a reason can never carry an address, a name or a subject.

Emission (stated for the shipped rules; in general a rule’s own codes are those of the conditions its when names that held, cap codes accompany coverage: 'partial' and grace.waiting accompanies graceElapsed: false while PENDING). coverage.no_parser is emitted on a partial_coverage receipt of a claim with no parser whenever a capped gmail.receipt entry overlaps its window, whatever else limited coverage; with the shipped policy (receipt types read only gmail.receipt) that is every such receipt. match.best (with the best candidate) or match.none on every claim receipt, never on claim-less ones; cp.* from the best candidate only; the coverage-capping codes (coverage.no_account, coverage.multiple_accounts, coverage.calendars_hidden, coverage.changes_unfollowed, coverage.change_unprovable, coverage.clock_repaired, coverage.list_unsettled, coverage.account_partial, coverage.no_parser) only when ruleId is partial_coverage; coverage.none only for no_coverage; coverage.account_unbound only on NOT_FOUND; coverage.never_completed is the pending rule’s own code once that outcome finalises by time (it replaces grace.waiting); grace.waiting only for pending while PENDING; unasked.* only on UNASKED; allowlist.unchecked only when that condition was not applicable and the receipt is not OVER_SCOPE by another rule; the verified, partial, not_found and default_unverifiable rules add no rule code. Golden files compare reasonCodes as an exact, ordered list.

Reason codes are ordered: the rule’s own code first, then allowlist.unchecked if emitted, then coverage reasons, then match and counterparty reasons, each group in table order; override.user and superseded come last.

16. Worked examples

Each example is a golden scenario: a JSON file in extension/tests/golden/ that golden.spec.ts grades through the core on every pnpm test (every file below runs through gradeBatch). The file is the source of truth; an example restates its inputs and the hand computation in its notes. The canonical worked example is the §2 demo as a test, extension/tests/unit/core/demo.spec.ts (task 1.10): it builds the demo’s ledger from synthetic data and walks it step by step, each assertion naming the section that decides it. §16.13 is the same story as a golden file; the two agree on every grade, rule and state.

Numbers are in basis points (bp, §5.3), from §12: timeProximity 2500, counterpartyExact 3500, counterpartyDomain 1500, counterpartyName 2500, subjectOverlap 1500; thresholds.verified 8500 and thresholds.partial 5000; unasked.signals agentSessionActive 4000 and userIdle 3000; unasked.threshold 6000. A score is S = N / D, with N = Σ wᵢ·(feature value) and D = Σ wᵢ over the applicable features; S ≥ T exactly when 10000 · N ≥ T · D, after multiplying N and D by the denominators of fractional feature values (§5.3). Unless stated, times are on Tuesday 2026-10-06 in −04:00, claims have minute precision, and the grant is the default one: every type allowed, no allowlist, no ceiling, no accounts. “Window” is the §4.1 claim window and “unit” its precision unit [start(p,t), end(p,t)].

Example Golden file Receipt Grade Rule State Score
16.1 01-exact-match-same-minute.json c1 VERIFIED verified FINAL 1/1
16.2 02-domain-match-subject-partial.json c1 PARTIAL partial FINAL 1/2
16.3 03-pending-grace-not-elapsed.json c1 UNVERIFIABLE pending PENDING none
16.4 04-not-found-after-grace.json c1 NOT_FOUND not_found FINAL none
16.5 05-gmail-never-synced-in-window.json c1 UNVERIFIABLE no_coverage FINAL none
16.6 06-purchase-without-receipt-parser.json c1 UNVERIFIABLE partial_coverage PENDING none
16.7 07-unasked-attribution-0-7.json o1 UNASKED unasked FINAL none
16.8 08-type-not-in-grant.json c1 OVER_SCOPE over_scope FINAL 1/1
16.9 09-amount-above-ceiling.json c1 OVER_SCOPE over_ceiling FINAL none
16.10 10-two-claims-one-observation.json c1 NOT_FOUND not_found FINAL none
16.10 10-two-claims-one-observation.json c2 VERIFIED verified FINAL 1/1
16.11 11-duplicate-observation-content-hash.json c1 VERIFIED verified FINAL 1/1
16.12 12-timezone-midnight-edge.json c1 VERIFIED verified FINAL 787/792
16.13 13-demo-section-2.json c1 VERIFIED verified FINAL 12/13
16.13 13-demo-section-2.json c2 NOT_FOUND not_found FINAL none
16.13 13-demo-section-2.json o2 UNASKED unasked FINAL none
16.14 14-demo-calendar-coverage-absent.json c2 UNVERIFIABLE no_coverage PENDING none

“Score” is the best candidate’s exact score; “none” means no candidate was scored (a claim receipt then carries match.none; an UNASKED receipt has no claim to score). A PENDING receipt by rule pending carries the placeholder grade UNVERIFIABLE (§2). extension/tests/unit/docs/worked-examples.spec.ts checks every row of this table, and every row of §16.15, against the golden files.

16.1 Exact match, same minute

Golden: 01-exact-match-same-minute.json.

16.2 Recipient domain match, subject partial

Golden: 02-domain-match-subject-partial.json.

16.3 No observation, grace not elapsed

Golden: 03-pending-grace-not-elapsed.json.

16.4 No observation, grace elapsed

Golden: 04-not-found-after-grace.json.

16.5 Gmail never synced in the window

Golden: 05-gmail-never-synced-in-window.json.

16.6 Purchase with no receipt parser

Golden: 06-purchase-without-receipt-parser.json (and 78-purchase-no-parser-cap.json).

16.7 Orphan observation, attribution 0.7

Golden: 07-unasked-attribution-0-7.json.

16.8 Claim type not in the grant

Golden: 08-type-not-in-grant.json.

16.9 Amount above the ceiling

Golden: 09-amount-above-ceiling.json.

16.10 Two claims, one observation

Golden: 10-two-claims-one-observation.json.

16.11 Duplicate observation

Golden: 11-duplicate-observation-content-hash.json.

16.12 Time-zone edge: 23:58 and 00:01

Golden: 12-timezone-midnight-edge.json.

16.13 The §2 demo

Golden: 13-demo-section-2.json. Canonical test: extension/tests/unit/core/demo.spec.ts. The arithmetic below is golden 13’s. demo.spec.ts runs the same story through gradeBatch with its own inputs (the sent row’s subject is exactly “Q3 deck”, so c1 scores 1/1; Gmail checks at 14:10, 14:40 and after the window; a Monday-start week view; idle 14:00–14:09), and asserts the same three outcomes.

16.14 The §2 demo with Calendar never opened

Golden: 14-demo-calendar-coverage-absent.json.

16.15 Where each rule fires

Every rule in policy/default.json, in policy order, plus the terminal rule, with the worked examples above and other golden files that end on it.

Rule Then Worked examples Other golden files
unasked UNASKED 16.7, 16.13 46, 47, 100, 101, 104, 105, 106, 107, 111, 115
over_scope_observed OVER_SCOPE none 50
over_scope OVER_SCOPE 16.8 74
over_allowlist OVER_SCOPE none 36, 64
over_ceiling OVER_SCOPE 16.9 34, 35
over_ceiling_cumulative OVER_SCOPE none 33, 77
no_target UNVERIFIABLE none 26
underspecified UNVERIFIABLE none 24, 25
no_coverage UNVERIFIABLE 16.5, 16.14 45
verified VERIFIED 16.1, 16.10, 16.11, 16.12, 16.13 29, 99, 103, 108, 112
partial PARTIAL 16.2 15, 111
pending PENDING 16.3 44
partial_coverage UNVERIFIABLE 16.6 41, 42, 102, 103, 108, 109, 110, 114
not_found NOT_FOUND 16.4, 16.10, 16.13 43, 108, 109, 110, 111, 114, 115
default_unverifiable UNVERIFIABLE none 51

default_unverifiable is the terminal rule (§6): it is not listed in policy.rules and matches when nothing else does. Under the default rules every claim receipt matches an earlier rule, so golden 51 reaches it with a policyPatch that drops not_found (its notes say so); of the other files in this table, only 33, 34, 35, 77 and 99 patch the policy, and only to list receipt merchants in receiptMerchants (“acme”; “stripe” and “amazon” in 99).