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’scontentHash(§9), and the §3 and §13gradeBatchsignatures matchbatch.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
loadPolicydid not return, and the loader refuses a rule that concludes a grade without naming its evidence, and athresholds.partialof 0 bp (§3, §6, §5.3); a score condition (bestScoreGte,bestScoreLt) is not applicable to a claim that is never scored (§6.1);graderefuses 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) ingradeBatchandreplay: 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) forgradeBatchandreplay, and an override releases or takes an observation only in batches evaluated strictly after itsat(§3, §4.3, §9); a claim with no currency is counted inspendCurrencyby the cumulative ceiling and bypriorSpendCents(§6.1, §6.3); a claim never counts its own id in its prior spend (§6.3);gradeBatchadds the graded claimsactivity.claimsomits to the spend and adjacency history (§3);projectrefuses a row whose allowlisted value fails its field schema or is held by a getter, a setter or a prototype, the ledger refuses a rowprojectcould not have returned, keeps the earliestobservedAtof 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 samerawHashorcontentHashin anything butobservedAt(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 namingbestScoreLtbefore the rule that catches such a claim (the shipped order is unaffected:no_target,underspecifiedandno_coveragecome 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:amountAboveCeilingis false, never not applicable, within its guard (§6.1);isFinalizabletakes oneFinalityInputobject (§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’sobservedAtis 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 withrecordBatchInstantbefore it reads (§3, §10). A claim’sextractoris not compared under its rawHash (§10).Request.conversationIdis a vendor id token, and scopes and allowlist entries are one line (§13). Batch instants are strictly increasing:gradeBatchrefuses a claim pre-assignment from a grading at or afterevaluatedAt(stale_evaluated_at), andclaim_fixednames 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 inreverted, the override stays in force for the batches up to itsrevertedAt(pre-assignments gainreleasedspans, anduntilnow marks only the end of a reverted override from PENDING), andregraderefuses a receipt computed while a reverted override held the subject (regrade_held) (§3, §4.3, §7, §8, §9). The policy loader refuses anunasked.thresholdof 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 andgradeBatchnow 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 onsrc/corecatch and what they cannot (§14 items 8 and 9), and the escaping of the file nameattest replayechoes (§9).v1.8 is the fourth Sprint 1 review close (Decisions.md,
[1.close-r4]entries). Rules it changed: an override’satand a revert’s instant are stamped bywriteUserEvent, inside one readwrite transaction overreceiptsandmeta, 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 assightedAt, set only by the ledger and strictly beforeobservedAt(§10, §13). §4.1’scalendar.createdfirst-seen rule, the §7 calendar anchor (attribution windows and UNASKED finality), the representative of acontentHashand the ledger’slate_sightingread the first sighting (sightedAt, elseobservedAt;order.tsfirstSeenAt), whileobservedAtalone still decides which batches read a row (§3, §4.1, §7, §10). A candidate’srefHashbindssightedAtwhen present (§3). A re-read of a stored claim is a no-op however early it was sighted (a claim’s sighting grades nothing), solate_sightingis raised for observations only (§3, §10).writeUserEventalso 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 withoutsightedAt(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 acalendar.createdclaim 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 globalsrc/corereads to an allowlist of ES2023 built-ins and readRegExponly 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.eventonly, set by the ledger from the stored snapshot chain, never afterlastSyncAt): since when that Calendar view followed the window’s changes (§3, §13). Acalendar.updatedorcalendar.deletedclaim counts agcal.evententry 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 reasoncoverage.changes_unfollowed(§6.1, §15). Agcal.eventrow is an orphan only if it is a change a view followed: a covering entry hadchangesSincebefore 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 carrychangesSince, 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 nogcal.eventrows and no calendar change claims grades as under v1.8. A coverage entry withoutchangesSincehashes as it did before (§3). Goldens99–103are new and86gainedchangesSince; files from100on 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.evententry of the same observer and account whose window is the row’soccurredAt(a point) and whoselastSyncAtandchangesSinceare 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). Agcal.evententry counts towards full coverage of anycalendar.*claim only if itslastSyncAt ≥ end(p,t) + graceMin; any other overlapping entry is capped withcoverage.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.tscalendarBound). §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 agmail.sententry whosewindowEndis not itslastSyncAtand of agcal.evententry that reaches a day its snapshot hid. Grades change only for acalendar.createdclaim whose window the entries synced at or afterend(p,t) + graceMindo not contain whole while older ones did (never NOT_FOUND now:partial_coverageuntil 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 nogcal.eventaccount when a marker brings a second account into its coverage (cappedcoverage.multiple_accounts, as that account’s own windows would be, §6.1); no golden from01to103changed. Goldens104–108are 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) acalendar.updatedorcalendar.deletedclaim 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). Everygcal.evententry over such a claim’s window is capped with the new reasoncoverage.change_unprovable; a change row a view saw still verifies or is PARTIAL evidence, and a claim with none ispartial_coverage, UNVERIFIABLE once it finalises by time.calendar.createdkeeps NOT_FOUND, with the meaning §6.1 now states. The v1.9 change-span cap goes with it: no coverage level readschangesSinceany more, the orphan rule still does (§3, §4.1, §6.1;conditions.tschangeSpanis removed andcalendarBoundis{syncMs, change}). (b) Coverage is per bound account: whengrant.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 reasoncoverage.account_partial(CAL-SIM-4; every source; an unbound grant keepscoverage.multiple_accounts) (§6.1). (c) No evidence on time alone: a pair none of whose content features (every applicable feature buttimeProximity) scores above 0 scores 0, so it is never assigned, never PARTIAL and never VERIFIED (CAL-SIM-5), and the loader refusestimeProximityinidentityFeatures(§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_coveragewhere 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_coverageinstead 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 now0/1), 102 (its cap iscoverage.change_unprovable) and 103 (c2 ispartial_coverage, no longer NOT_FOUND), with the reason in each file’s notes; the notes of golden 13 restate its c1 × o2 arithmetic. Goldens109–111are 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 nogcal.eventrow 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 insrc/core: what changed is what the ledger is given, sogradeBatchandreplaygrade 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. Golden112is 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 20calendar.updatedrows per event per day, a pass that holds one back recording its windows withoutchangesSinceand leaving the observer DEGRADEDledger.event_churn(SEC-11), so an event edited more than 20 times in a day has fewercalendar.updatedrows (a change claim one of them would have verified ispartial_coverage, never NOT_FOUND, §6.1); the service worker refuses agcal.evententry 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 DEGRADEDpage.busyinstead of OK with no coverage (SEC-10). Neither (a) nor (b) changes the expectation of an existing golden. (c) Gmail coverage (§3): thegmail.senthead window starts one minute after its oldest row’s minute while the list may go on (an enabledOlder, or none seen), because a send in that minute older than the oldest row is on the next page, unseen; and agmail.receiptpass 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, neversrc/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 DEGRADEDevent.unreadableinstead 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 changessrc/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 eachgmail.sententry 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 twogmail.sentintervals only as it merges twogcal.eventones: 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 changesrc/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 itsevaluatedAt, andcoverageHashreads them as a set, an empty list as none) (§3). Acalendar.createdclaim whose first-seen boundstart(p,t) − beforeClaimMin(§4.1) lies in a span has everygcal.evententry over its window capped with the new reasoncoverage.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.tscalendarBoundis{syncMs, change, repaired}. No existing golden expectation or id changed; golden114is 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 firedagentSessionActiveandactivityLogAdjacenttogether, 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, whenactivityLogAdjacentfires and every session ping within ±sessionMinof the anchor sits at the instant of an entry that fires it, only the heavier of the two signals fires (activityLogAdjacenton 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; golden115is 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 bygrade()in the offset ofevaluatedAt, 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 afinalFrommismatch 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 tomax(S, (1 − λ)·S + λ·m)when S is already at or abovethresholds.partial; λ isfeatures.modelScore(0.15), capped by the loader so that the model alone never verifies a partial match, andmodelScoreis never a pair feature (the loader refuses it infeaturesByTypeandidentityFeatures). 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
- The shape of the problem
- Grades and states
- Inputs
- Candidates: the window, retrieval, one-to-one assignment
- Features and score: every feature, applicability, the exact score
- Rules: every condition, the default rule order, cumulative spend, reasons
- UNASKED: attribution signals, always “likely”
- State machine, with the diagram
- Replay and the replay procedure
- Ids and hashes
- Normalisation
- Policy keys: every number in
policy/default.json - Module contracts
- Deviations from build_spec §7
- Golden scenarios: the file format and the ReasonCode table
- 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.
claim: the extracted claim, ornull(UNASKED and scope-diff receipts).candidates: observations for this claim after assignment. Each must be one §4.1 would return for the claim: of the claim’s type, from a sourcepolicy.sourcesByType[claim.type]lists, withoccurredAtinside the claim window (a calendar claim withouttargetAthas none), and forcalendar.createdfirst seen (sightedAtwhen it has one, elseobservedAt, §4.1) at or afterstart(p,t) − beforeClaimMin.graderefuses any other withGradeError('foreign_candidate')rather than grading a caller’s bug into a FINAL receipt (Hard Rules 5, 6). A candidate withobservedAt > evaluatedAtis ignored (below), not refused.gradesorts them by (occurredAtinstant,id) itself.grant: the Grant for the claim’s agent in force atcoverage.evaluatedAt.policy: the loaded, frozen policy: an objectloadPolicyreturned, never a copy, spread, clone or hand-built object (a copy keeps the original’spolicyHashand the parts its memos are keyed on).grade,gradeOrphan,gradeClaim,gradeBatch,features,pairScorer,RowScorer,isAssignable,applicableFeatures,isUnderspecified,attribute/attributor,replay(for every policy in the snapshot) and the ledger’supsertObservation(§10) throwPolicyError('policy_not_loaded')for any other object (Hard Rules 4, 6). ItspolicyHash(SHA-256 of its canonical JSON) is stored on every receipt.-
coverage = { evaluatedAt: IsoTs, observers: ObserverCoverage[] }, whereObserverCoverage = { observerId, source: 'gmail.sent' | 'gmail.receipt' | 'gcal.event', lastSyncAt, windowStart, windowEnd, accountHash?, allCalendars?, changesSince? }windowStart..windowEndis the span of time the pass could see (a closed interval; a point,windowStart = windowEnd, is one). Observer obligations (Sprint 2): a coverage entry exists only for an extraction pass whosehealth()was OK, and never before that pass’s observations are stored (Hard Rules 5, 6). A pass in whichprojectrefused a row (RedactError) or the ledger refused an observation (LedgerError('invalid_input'),LedgerError('id_conflict')orLedgerError('late_sighting')) is DEGRADED and writes no coverage entry for its window: a refused row is not evidence of absence (Hard Rules 5, 6). How a pass is stored depends on its source (v1.10): a Gmail pass (gmail.sent,gmail.receipt) stores each row on its own, collects the refusals, and records no coverage after any refusal; a Calendar pass is stored all or none (gcal.event, below). The background answers everyobserver.observations,coverage.pingandcalendar.snapshotmessage with{ok: true}or{ok: false, code}(a closed code such asrefused,late_sighting,invalid_input); an observer posts each message of a pass only after the previous one was answeredok, and a pass whose post was refused or lost stops there, so no coverage ever follows rows that were not stored. A pass may write several entries, one per window it saw, or none: a pass that covers nothing by design (a Sent page past the head, an opened conversation with no receipt, a Calendar grid still drawing) is OK, never DEGRADED, and a page the observer does not read (a Gmail view other than the Sent list forgmail.sent, one with no conversation open forgmail.receipt) is IDLE, neither OK nor broken (sprint_2 2.11: its report never replaces the stored status a page in another tab gave). What each source’s entry asserts:gmail.sent: only a pass that saw the head of the Sent list (the first page, withNeweroff and no refused row on it) writes coverage, withwindowEnd = lastSyncAt. Its window starts at the oldest row’s minute only when the page provably ends the list (everyOldercontrol disabled); while the list may go on, it starts one minute later (v1.12): Gmail shows a send’s minute only, so a send in that minute that is older than the oldest row is on the next page, unseen, and a window holding it would make a claim about it NOT_FOUND. A row of several messages gives no row, since the list shows only its newest message’s minute, and the window starts no earlier than one minute after the newest such row (v1.13): every send of the user’s in that conversation was made by then, so the window holds only sends the pass timed. A row holds one message only 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 (v1.15): a mail composed as a draft first keeps the draft’s older id as its thread id, and Gmail draws a count for every conversation of two messages or more. Any other row whose ids are all there holds several, and a list that draws no count is read by its ids alone. A head with no whole minute left after those bounds writes no coverage. Any other Sent page writes its observations and no coverage, by design (OK, never DEGRADED): in conversation view a send from its span can be listed on an earlier page under a later reply’s date, so an older page cannot vouch for its span (Hard Rule 5). The service worker refuses (invalid_input, DEGRADED) agmail.sententry whosewindowEndis not itslastSyncAt.gmail.receipt: a pass that parsed an opened receipt from a registry sender (a platform parser) writes one entry per such message, whose window is that message’s instant (windowStart = windowEnd= its date, the row’soccurredAt; two receipts in one minute share one entry). A receipt from any other sender (the generic parser) writes none (v1.12): anyone can send one, so it is evidence for a claim, never coverage; and a registry receipt dated in the same minute as a generic one of the same pass writes none either, so no entry a pass writes holds a stranger’s row. Known limit: rows carry no parser, so a registry receipt’s entry written by another pass in the same minute holds a generic row, which can then be an orphan (attacks-that-work.md). It asserts only that this message was read, never that the rest of the inbox around it was. A point never contains a claim window, so receipts alone never make a claim’s coverage full and a purchase is never NOT_FOUND on them (§6.1); an exact receipt still verifies, and an unclaimed registry receipt read at its own instant can be an orphan (below, §7). A list view (IDLE: nothing to read), or a pass that parsed nothing (OK, by design), writes no coverage. The service worker refuses (invalid_input, DEGRADED) agmail.receiptentry wider than one instant.-
gcal.event: one entry per run of whole visible days (a day that hides events behind “N more” is in none), and none while the grid is still drawing (OK: a half-drawn week is never read as deletions). A page that has looked busy through 5 minutes of passes is DEGRADEDpage.busyuntil a pass finds it drawn (v1.12): a page that never stops looking busy is never OK with no coverage (Hard Rule 6). It setsallCalendars: trueonly if every calendar was visible. After a clean pass with a known account, and after its observations and coverage, the observer posts the pass’scalendar.snapshot; the service worker derives that pass’s rows from the stored snapshot of the range and account and the new one, and stores them with the pass’s entries. The ledger, never the observer, setschangesSinceon agcal.evententry from the stored snapshot chain of that range and account: the instant from which the observer had a complete, comparable snapshot of the window (the chain’s firstreadAt, never below the clock floor, below), so every change that still differs between two consecutive snapshots of the chain is reported. A change made between two snapshots and then moved, changed again or undone before the next one is not (CAL-SIM-6), which is why no coverage level readschangesSincesince v1.11 (§6.1); the orphan rule does (below, §7). A pass that compared nothing (no stored snapshot of the range and account, one read under other selectors, or one that did not see the window whole: a calendar since shown, a day since no longer hidden) starts a chain: its entry haschangesSince = lastSyncAt, or none. A window entry with no account has none. A complete pass that saw an event missing for the first time records no coverage until a later complete pass confirms the deletion (the event missing again) or clears it (the event back): a deletion is reported only after the event is absent from two consecutive complete snapshots, and the confirmed row is first seen at the first absence. A pass that cannot vouch for absences (a day hidden behind “N more”, a calendar named twice) records its windows withoutchangesSince, and so does a pass that held an update back (Update rows per event, below). A run of followed changes also breaks when the set of shown calendars changes. The service worker checks each entry of a pass with an account against that pass’s snapshot before it stores anything, and refuses the whole pass (invalid_input, DEGRADED) when an entry starts outside the snapshot’s range or reaches into a day the snapshot hid. It refuses (invalid_input, DEGRADED) anygcal.evententry, with an account or without, shorter than a day less an hour or longer than 42 days and an hour (the most days one range shows, with a DST hour either way): no pass can have seen it (v1.12).First-look markers (v1.10). 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, every row of a page with no account, a change noticed late (v1.11, below), or any other case) gets a first-look marker, written by the ledger in the same transaction as the row: a
gcal.evententry of the same observer and account (none, for a page with no account) whose window is the row’soccurredAt(a point), whoselastSyncAtandchangesSinceare the row’s first sighting, and whoseallCalendarsis its pass’s (left out when the pass’s is not known as the row is stored, as on a page with no account, whose rows are stored before its pings: the marker then counts as hidden, §6.1, which only adds a reason to apartial_coveragereceipt). Rows carry no account and no pass, so without a marker a first sighting whose start no window of its pass holds (an event that began before the visible range, one on a day hidden behind “N more”, one from a pass that recorded no window) would lie inside a span another view followed (the same account’s chain, or another account’s view of the same days) and read as a change Attest saw happen (Hard Rule 7). With it, orphan rule (b) finds the row already there (changesSinceequals its first sighting), wherever its start lies. A marker follows nothing (changesSince = lastSyncAt), so it never makes a row an orphan by rule (a), and it is a point, so it never makes coverage full (§6.1). Under v1.10 and v1.11 a change row (acalendar.created,calendar.updatedorcalendar.deletedthe comparison found) whose comparison base was recent got no marker: its pass’s chain followed it. Since v1.12 every change row gets one (Changes the account did not make, below). A marker is an ordinary coverage entry: it is bound bycoverageHashlike any other, it is in the closure under overlap a batch reads (Background obligations, below), and retention never removes it while a window still holds its row’s start: the purge cuts every coverage window at its cutoff, so a marker goes only when no window over its point is left, and a row kept past the cutoff (another receipt’s evidence) then lies in no window and is no orphan.Changes noticed late (v1.11). A Calendar change is first seen at the pass that finds it, and §7 attributes it from that instant. It happened somewhere between the read of its comparison base (the stored snapshot it was compared with; for a confirmed deletion, the snapshot its first absence was compared with) and that first sighting. When the base was read more than the attribution window before the first sighting, or is not known, the change may lie outside the windows attribution reads around that instant, and whoever was active when Attest looked again could be named for a change made long before (Hard Rule 7). The attribution window is the narrowest of the §7 windows,
min(unasked.windows.sessionMin, idleMin, adjacentMin)minutes (2 with the shipped policy): within it, every window attribution reads around the first sighting holds every instant the change could have happened at (src/background/calendar-step.tsstaleChangeMs). Such a creation, update or deletion is stored as a first sighting, with its first-look marker: it still verifies or is PARTIAL evidence for a claim (§4.1), and it is never an orphan. A visible Calendar tab is read at least once a minute even when nothing on it changed, and again as soon as it becomes visible (OBSERVER_HEARTBEAT_MS, src/observers/base.tsheartbeatMs), so a range open in front of the user keeps a base inside the attribution window. Known limit: when Attest looks at a range less often than that (a tab left in the background), the changes it then finds are never UNASKED: a missed UNASKED, never a false one.Changes the account did not make (v1.12). Attest accuses an agent only of an action it saw happen (Hard Rule 7), and a change Attest saw on the account’s calendar is not always one the account made: Google adds an invitation from anyone (its default setting), an organiser moves or cancels an event the user was invited to, and a colleague edits an event on a shared or subscribed calendar the grid shows. A snapshot holds no fact that tells these apart (no organiser, no response status, no calendar ownership:
src/observers/gcal/snapshot.ts), so the service worker knows of no change the account made (src/background/calendar-own.tsaccountMadeChange) and stores every creation, update and confirmed deletion as a first sighting, with its first-look marker, however recent its comparison base: nogcal.eventrow is an orphan, and no Calendar row is UNASKED. Each row keeps its type, its fields and its first sighting, so it still verifies, or is PARTIAL evidence, for a claim (§4.1). Carry-forward (sprint_2 2.8): confirm on real pages which facts say that the account organises an event and owns the calendar it is on (read-only, best knowledge), add them to the snapshot as derived facts, and store as a change only a change to such an event; the rules above (a change noticed late, a change another range held first) then apply to it as before. Known limit: until then a real change an agent made to the calendar is never UNASKED either: a missed UNASKED, never a false one (attacks-that-work.md).Update rows per event (v1.12). Every state of an event is its own row, and anyone who organises an event on the user’s calendar can edit it by script while a Calendar tab is open. The service worker stores at most 20
calendar.updatedrows per event per day, counted in the range’s record from the first of them (src/background/calendar-churn.ts). Past that it holds the event’s further updates back while its comparison base still follows the page, records the pass’s windows withoutchangesSince(the run of followed changes breaks there) and leaves the observer DEGRADEDledger.event_churnuntil a pass holds nothing back (Hard Rule 6). Creations, deletions and first sightings are never held back. Acalendar.updatedclaim that a held-back row would have verified ispartial_coverage: a change claim is never NOT_FOUND (§6.1).A Calendar pass is stored all or none (v1.10). Its rows (changes, and first sightings with their markers), its snapshot record, its coverage entries and the ledger meta are written in one readwrite transaction that aborts on any refusal. A refused pass (
late_sighting,invalid_input, …) leaves no row, no marker, no record and no coverage, and because no record was stored, the next pass derives its sightings again, first seen at that pass. A page with no account posts its rows itself, before its pings: that message’s rows and their markers are stored all or none, and its entries only after them. So nogcal.eventrow stays stored without its marker, nor a pass’s rows without the entries that pass could record. This narrows, for Calendar rows only, the 2.7 rule that each row of a message is stored on its own.changesSince ≤ lastSyncAtalways (the schema refuses anything else). Twogcal.evententries merge only when theirchangesSinceis the same, or both have none: a merged entry must never claim changes were followed over a span one of its parts did not follow. A marker merged into an entry of its ownchangesSincekeeps blocking its row, because the merged entry still began comparing at that first sighting. Entries withoutchangesSincemay merge, because the orphan rule (b) reads them by their latest sync: a later sync only stops more rows from accusing an agent. A merge never moves an entry’slastSyncAtover days the later pass did not see again: §6.1 reads eachgcal.evententry’slastSyncAtas the instant every day of its window was last seen. Since v1.14 the same bound holds forgmail.sententries (§6.1 caps a Sent view synced before a claim’s grace sync): two merge only when they synced at one instant, or the earlier-synced window lies inside the later-synced one. - How each observer reads its page, what one pass posts and in what order, and what every DEGRADED reason code means:
docs/adapters.md(§3, §4, §8). What the ledger stores from it:docs/privacy-architecture.md.
context(required, hashed):{ priorSpendCents: number, attribution?: Attribution, scope?: { observed: string }, modelScores?: { observationId, score }[] }.AttributioncarriesadapterLastSyncAt(the attributed agent’s latest adapter sync) sogradecan decide UNASKED finality.modelScores(v1.19) are the advisory model’s scores of the claim’s candidates, in basis points, at most one per observation and in id order (§5.4); being part of the context, they are hashed intoinputsHashand stored with the receipt’s inputs, so replay applies exactly the scores the grading had.
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).
calendar.*: centred on the claimed event starttargetAt, not onclaimedAt:[targetAt − calendarDays, targetAt + calendarDays]. A calendar claim withouttargetAtis graded by ruleno_target.- every other type:
[start(p,t) − beforeClaimMin, end(p,t) + afterClaimMin], wherestart(p,t)is the start of the precision unit containingtandend(p,t) = start(p,t) + unit(the closed interval[start, end]; for minute precision, 14:02:00 → [14:02:00, 14:03:00]). A claim may carryclaimedAtEndonly withdayprecision, in(claimedAt, start(p,t) + 25 h]: thenend(p,t) = claimedAtEnd. Adapters setclaimedAtto the unit start forhouranddayprecision, written in the offset in which it reads 00:00 (resp. hh:00). Adapters setclaimedAtEndfordayprecision to the next local midnight (computed with the injected offset function); the core never relies onclaimedAtbeing the unit start (start(p,t)is always the start of the unit containingt), because a local day is 23 or 25 hours long on a DST change andstart + 24 hwould cut off its last hour (a false NOT_FOUND).
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:
regrade(next): PENDING →next, wherenextisgrade()’s receipt for the same subject (same id), PENDING or FINAL, withnext.computedAt ≥ computedAt; on an UNASKED receiptnextmust be UNASKED (a PENDING UNASKED that re-evaluates to anything else is deleted by the caller, never regraded). On a FINAL or USER_OVERRIDDEN receipt it throwsIllegalTransitionError: FINAL receipts are never re-graded, and grant changes affect PENDING receipts only. Each precondition is a refusal, never a silent fix: a superseded receipt is never regraded (regrade_superseded: its observation is fixed to the superseding FINAL claim, so the caller deletes a PENDING one), another subject issubject_mismatch, an earliernext.computedAtisregrade_stale, an UNASKED receipt to another grade isregrade_not_unasked, and anextthat is notgrade()’s output (USER_OVERRIDDEN or superseded) isinvalid_event; all throwIllegalTransitionError. The storedrevertedlist is kept. Anextcomputed while a reverted override still held the subject isregrade_held(a claim: at or before the lastrevertedAt; an orphan: inside(at, revertedAt]). Anextthat carriesrevertedisinvalid_event.override({grade: VERIFIED | NOT_FOUND, reason (required, non-empty), at}): from PENDING or FINAL → USER_OVERRIDDEN, storingpreviousState; anatbeforecomputedAtor before the lastrevertedAtthrowsIllegalTransitionError(invalid_event). The computedgradeis kept; the override sits beside it, so replay shows an override, never a changed grade.revert({at}): USER_OVERRIDDEN →previousState(Sprint 4.3: overrides are reversible). The override is appended torevertedwithrevertedAt = atand the computed part it sat beside; its reason is not kept.atis stamped by the caller inside the transaction that writes it; anatbeforeoverride.at, or one that is not a timestamp, throwsinvalid_event, and one revert past the cap throwsinvalid_result. A revert never erases what a batch saw (§4.3). A deviation from “terminal” in task 1.6, logged. The side panel (Sprint 4.3,src/ledger/user-events.ts) refuses to revert an override from FINAL to NOT_FOUND once another claim’s receipt names one of its observations (evidence_taken), and says the evidence now belongs to that claim: reverting it would leave both receipts naming one observation.supersede(claimId): only on UNASKED receipts, in any state; setssupersededBy, replacing an earlier claim (the same claim changes nothing). The background applies it only once the superseding claim’s receipt is FINAL, so an assignment that later changes cannot leave a stale supersession. Superseded receipts are excluded from counts, alerts and the digest: a receipt queued for an alert and superseded before the alert is settled shows none (Sprint 4.6,src/ledger/alerts.ts).
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:
- Write the ledger to a JSON file in the replay format. It is the ledger export (
extension/src/ledger/export.ts, formatattest-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’sinputsHashand naming its batch’s coverage bycoverageHash;batchCoverages, each batch’s coverage once, keyed by that hash (schema version 7: every receipt of a batch was graded with the same coverage); andpolicies, the policy documents keyed by thepolicyHasheach receipt names.extension/tests/tools/attest-snapshot.tsdocuments 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, andbatchCoveragessince schema version 7). - From the repo root, run
pnpm attest replay <export.json>. The rootattestscript runs the extension’sattestscript,tests/tools/attest-cli.tsundertsx, which builds theLedgerSnapshotabove from the file and callsreplay(snapshot)(extension/src/core/replay.ts). A relative path is read from the directorypnpmwas run in. - 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_MISMATCHESinattest-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);ReplayReportandREPLAY_FIELDSinreplay.tshave 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)
- SHA-256 in pure TypeScript (synchronous, runs in Node and the browser), tested against the FIPS 180-4 / NIST vectors and Node’s
crypto. Input is the UTF-8 encoding of the string; a lone surrogate is encoded as U+FFFD (as WHATWGTextEncoderdoes), so wherever two strings must stay distinct they are hashed throughcanonicalJson(which escapes lone surrogates):rowKeyHash = sha256Hex(canonicalJson(rowKey)). - Canonical JSON: object keys sorted by UTF-16 code unit; arrays in the given order (callers sort set-like arrays first, §3);
undefinedproperties dropped; strings exactly as ES2019JSON.stringify; numbers in JavaScript’s shortest round-trip form;-0written as0;NaNand±Infinitythrow. - Ids from hashes: two zero bits followed by the first 128 bits of the hash, as 26 uppercase Crockford base32 characters (always a valid ULID: first character 0–7). The
Ulidschema accepts only the uppercase form, so one id has one spelling; every currency is a 3-letter uppercase ISO 4217 code.Observation.id = idFromHash(contentHash);Claim.id = idFromHash(rawHash)whererawHash = sha256(canonicalJson({agent, sourceKind, rowKeyHash or null, text, claimedAt truncated to its precision unit}))(textis the normalised claim text, computed by the adapter; the truncatedclaimedAtis the ISO string of the unit start inclaimedAt’s own offset, seconds included, milliseconds omitted): three identical “Sent email to Priya” lines at different times stay three claims (red-team strategy 1), while the same line re-rendered on every page load stays one (strategy 9);Request.id = idFromHash(sha256(canonicalJson({agent, conversationId, textHash, ts}))), receipt ids per §3. - contentHash (redact.ts) = SHA-256 of canonical JSON of
{source, type, ...the content fields (counterparty, merchant, amountCents, currency, subjectHint, occurredAt) that allowlistedFields[source] names and the row supplies}, plusrowKeyHash(a hash of the vendor’s own row id) when the observer has one.subjectHintis at most 80 Unicode code points (a surrogate pair counts once);projectcuts a longer hint on a code-point boundary and hashes the stored text. So the same row seen twice is one observation, a created and a deleted event differ, and two separate identical sends in the same minute stay two. An observation carries one counterparty: a sent row with several recipients yields one observation per recipient (the counterparty is part of the hash). A Calendar event yields exactly one observation, never one per guest (Decisions [2.5]): its counterparty is the event’s first guest in ASCII-lowercase order with the signed-in account’s own address left out, or none, and acalendar.updatedthat added guests names the first guest it added. So one created event is onecalendar.createdrow and at most one UNASKED receipt (Hard Rule 7). A Calendar first look reports each event already there as acalendar.createdrow (§4.1), stored with its first-look marker (§3, v1.10); a later created row of the same event state shares itscontentHash, and the first sighting is kept.changesSince(§3) is coverage, never content: it is in nocontentHash.projectrefuses a row whose allowlisted value fails its field schema, or whose allowlisted field is held by a getter, a setter or a prototype rather than as an own data property (never invoked, never read), withinvalid_fieldrather than storing it without that fact (Hard Rules 5, 6). Before any ledger write,upsertObservation(db, observation, policy)refuses a policy objectloadPolicydid not return (PolicyError('policy_not_loaded')), then runsobservationIntegrityProblemsand refuses withLedgerError('invalid_input')a row holding a content field its source’s allowlist does not name, or acontentHashthat is notcontentHashOfits content. No row reaches disk thatprojectcould not have returned (Hard Rule 2). A write whosecontentHash(observations) orrawHash(claims) is already stored must be the same fact: one that differs in anything butobservedAt(and, for a claim, itsextractor, which is provenance) throwsLedgerError('id_conflict')and writes nothing (Hard Rule 6). A stored row is never rewritten. For an observation, a sighting at or after the stored row’s first sighting (itssightedAt, else itsobservedAt; compared as instants) changes nothing; an earlier one throwsLedgerError('late_sighting')and writes nothing, because a batch may already have graded with the stored row (§3, §9, Hard Rule 4) and §4.1 and §7 read its first sighting. For a claim, every re-read of a storedrawHashchanges nothing and returns the stored id (inserted: false), however early it was sighted: a claim’s sighting grades nothing (below), so an earlier one is no news, and refusing it would report the adapter DEGRADED for nothing (Hard Rule 6). A new row is stored withobservedAt= the latest of its sighting, the writer’s now (src/clock.ts) and the last recorded batch instant + 1 ms, read inside the readwrite transaction that writes it (stampObservedAt, src/ledger/batch-instant.ts). When that is later than the sighting, a new observation also stores the sighting assightedAt, so §4.1 and §7 still read when it was first seen.sightedAtis set only by the ledger:upsertObservationrefuses an observation that carries one (invalid_input,observationIntegrityProblems:projectnever returns it), the schema requires it strictly beforeobservedAt(absent means the same instant, so one fact has one spelling), and it is never part ofcontentHash. A claim keeps no sighting: nothing grades a claim’sobservedAtbut batch membership (§3); its own time isclaimedAt. - No id or hash depends on the engine’s Unicode. SHA-256 and canonical JSON work on code units and code points only. Every text-derived value that feeds a score, a hash or a stored field (a folded
name(), name and subject tokens, stop-word keys, amounts, resolved times) comes from the §11 readers, which read text as Unicode 15.0 does whatever the engine (§11, Unicode baseline). So identical inputs give the sameinputsHash, signals, grade and receipt in every supported Chrome and under replay in Node.
11. Normalisation (normalize.ts)
- Addresses.
email(raw)extracts the address (a display name"Priya" <p@x.com>is dropped), and setsconfusablefrom the raw extracted address: any code point above U+007F, or a domain label starting withxn--in any ASCII case. Addresses are never NFKC-normalised (NFKC turns𝐩and fullwidthpintop); only ASCIIA–Zare lowercased, by table, neverString#toLowerCase(which mapsK, the Kelvin sign, tok). Claims and observations store the address exactly as read; comparison happens infeatures.ts. Two addresses compare equal iff they are code-point-identical after ASCII-only lowercasing (no NFC: NFC still folds the Kelvin, Ohm and Angstrom signs); a+tagdifference is reported, not erased. IDN domains are decoded from punycode for display only. - Names. NFKC, diacritics folded, zero-width and bidi marks removed, whitespace collapsed, lowercased, split into tokens.
- Amounts. Integer minor units and ISO currency from
$1,234.50,CA$10,€9,99,USD 10.00,¥980; integer arithmetic only; the minor-unit exponent per currency (USD 2, JPY 0, KWD 3, …) comes fromunits.ts; bare$resolves to the caller-supplied default currency;¥is JPY (CNY is writtenCN¥orCNY); a lone separator that reads both as a decimal point and as a thousands group (KWD 1.500) is rejected, not guessed. - Subjects. Read each lone surrogate as U+FFFD (see Unicode baseline) → remove zero-width and bidi marks → NFKC → lowercase (final sigma decided as Unicode 15.0 decides it) → split on runs of characters that are not letters, digits or marks (
[^\p{L}\p{N}\p{M}]+with the Unicode 15.0 classes, see Unicode baseline; so a combining mark NFKC cannot compose never splits a word) → drop empty → strip leadingre,fw,fwdtokens repeatedly → droppolicy.normalize.stopWords→ set. A subject holding a bidi override or embedding yields no tokens; subjects fold compatibility digits with NFKC deliberately (Q³→q3): subject overlap is a soft feature that can never verify a claim on its own. - Text that displays differently from how it is stored is not read. A string holding a bidi override or embedding (U+202A, U+202B, U+202D, U+202E), or a
\p{No}digit (Unicode 15.0’s General_Category No: 915 code points, 749 of which NFKC leaves unchanged) that NFKC would turn into a different number (superscripts, subscripts, circled digits, fractions), yields null fromamountandresolveRelativeand no name tokens; bidi marks and isolates are only stripped. -
Vendor-relative times (“2:03 PM”, “Yesterday”, “Thu”) resolve against a reference instant and an injected UTC-offset function; core never reads a time-zone database. The result carries a precision ( minutehourday) and the unit’s start and end instants. Rules: a weekday name means the most recent such day strictly before the reference day; “N units ago” is the floor range[ref − (N+1)u, ref − Nu]at the finest aligned unit that contains it (“N days ago” counts calendar days); a clock time that is skipped or ambiguous on a DST day widens to day precision. - Unicode baseline (15.0.0). Every character property §11 uses is Unicode 15.0’s, read from tables generated from the UCD 15.0.0 files (
scripts/gen-unicode-baseline.ts; sources inscripts/ucd/15.0.0/; generatedsrc/core/unicode-table-*.ts), never from the JavaScript engine. That covers letters, digits and marks (\p{L},\p{N},\p{M}above),\p{Mn}(diacritic folding),\p{No},\p{Cf}and Default_Ignorable (invisible marks), whitespace (ECMAScript\s/trim(), and White_Space), and Cased/Case_Ignorable (final sigma). 15.0 is the Unicode of the oldest supported Chrome (minimum_chrome_version 120: ICU 73.1). Every reader treats a code point 15.0 does not assign as unassigned (Cn): it is not a letter, digit, mark, space or invisible mark, and has no compatibility decomposition and no case mapping. So it separates subject and name tokens and belongs to none;name()keeps it as written; an amount or time whose digit is one does not parse ($1followed by OUTLINED DIGIT ZERO, U+1CCF0, is null); a stop word holding one matches nothing. A code point 15.0 leaves unassigned but already makes Default_Ignorable (U+2065, U+FFF0–U+FFF8, unassigned E0000–E0FFF) is stripped as invisible. A lone surrogate (half of a UTF-16 pair with no partner) reads as U+FFFD, the replacement character it displays as, before any reader removes a character. Removing an invisible or nonspacing mark between two halves never joins them into a character the text did not hold (U+D833, U+200B, U+DCF1 is two U+FFFD, not OUTLINED DIGIT ONE), andname()keeps U+FFFD in its place. NFKC and lowercase come from the engine only for 15.0 characters, which Unicode’s normalisation and case-pair stability policies freeze. Lowercasing decides final sigma (U+03A3 → ς or σ, SpecialCasing Final_Sigma) from the 15.0 Cased and Case_Ignorable tables, as ICU does, and keeps a 15.0 character that an engine would lowercase into a newer one. The same inputs therefore give the same tokens, scores and grade in every supported Chrome and underpnpm attest replayin Node. The baseline never moves for receipts graded under it: moving it changes grading, and would first need the baseline version in each receipt’s inputs, with the 15.0 tables kept for replay (Decisions [SEC-1]).
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)
grade()takes a sixth argument,context(cumulative spend, attribution, scope), required and hashed (Hard Rule 4 needs every input in the hash).- Receipt ids derive from the subject (claim, orphan observation, scope), not from
inputsHash;inputsHashis stored. Idempotency is kept: same subject → same row. - Receipt gains
agent,policyHash,attribution,override,reverted, and structuredreasons. Claim gainstargetAtandclaimedAtPrecision. Observation gainsrowKeyHash. Grant gainsspendCurrencyandaccounts. - The policy gains the keys in §12, the over-scope rules move before
no_coverage, andnot_foundrequires full coverage. - 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 afterfinalizeUnverifiableHours. USER_OVERRIDDENcan be reverted (Sprint 4.3). A revert carries its instant and keeps the override inreverted, so a revert never erases what a batch saw (§8).src/coregainsunits.ts,hash.ts,sha256.ts,base32.ts,punycode.ts,ratio.ts,window.ts,conditions.ts,receipt.ts,batch.tsbeside the §6 file list; the no-literal test exemptssha256.ts,base32.ts,punycode.ts,units.tsandschema.tswith a reason each.- 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/coregainsunicode-baseline.ts(asBaseline,onBaseline,trimBaseline,lowerBaseline,BASELINE_CLASS) and the generatedunicode-table-assigned.ts,unicode-table-classes.tsandunicode-table-case.ts. These are written byscripts/gen-unicode-baseline.tsfrom the UCD files committed inscripts/ucd/15.0.0/(DerivedAge, DerivedGeneralCategory, PropList, WordBreakProperty; SHA-256 checked).tests/unit/core/unicode-tables.spec.tsfails when a table is stale or hand-edited, andtests/unit/core/unicode-fence.spec.tswalks the syntax tree of every file insrc/coreand its subdirectories (any file there other than a.tssource and the top-leveltsconfig.jsonfails). It refuses engine\p{…},\s,trim(), case mapping and Unicode case folding elsewhere: aniflag withuorv, anipattern 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 intests/unit/core/unicode-fence-controls.spec.tsandtests/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. - The purity fences on
src/core(Hard Rule 4) are three: lint (eslint-rules/core-fences.js,attest/core-determinismandattest/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 anysrc/coremodule loads. The typecheck backs them:src/core/tsconfig.jsonloads 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 globalsrc/corereads to an allowlist: the ES2023 built-ins less the restricted ones andeval,Function,Proxy,escapeandunescape. A host name no list foresaw (Image,clientInformation,open) therefore fails unless the file declares or imports it.RegExpmay be read only asnew RegExp(…)orx instanceof RegExp, because its legacy statics (RegExp.lastMatch,RegExp.$1) hold the last match any code made. The guard poisons the names lint givessrc/core’s no-restricted-globals andattest/no-network, which includeclientInformation(Chrome’s other name fornavigator) 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, insrc/core, the comments that would switch the typecheck off:@ts-expect-error,@ts-ignore,@ts-nocheckand triple-slash references. The grep test finds a read of a global in the syntax tree, so a local of the same name (src/corecalls its time windowswindow) is not the global.pnpm test:poisonruns every core, golden and property test under the guard and fails on any impure call, any changed built-in, or less than 95% ofsrc/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 testingDate.UTC.name(the guard wrapsDate.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) andRegExp’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 ofglobalThis(its settings) andnavigator(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 poisonschromeandbrowserbefore the first test rather than beforesrc/coreloads, because the test runner stubs them after the guard is installed, and it leavesglobalThisitself as it is, because Vitest’sexpectreads 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, andcore-poison.spec.tspoisons all three. -
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 bysrc/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", … }]
}
- Top level.
name(at most 120 characters),covers(at least one"<kind>:<id>"tag naming the mandatory scenario, edge case or contract rule the file proves),mode,inputandexpectare required.policyPatchis an optional RFC 7386 JSON merge patch onpolicy/default.json(an array replaces the whole array), and the patched document must load withloadPolicy.notes(optional, one string or a list) holds the hand computation, one step per line. - Refs. Claims are
c<n>, observationso<n>, grantsg<n>. Every ref a file uses resolves to an object of the file. - The model’s scores (§5.4, v1.19; batch mode only).
input.modelScoresgivesgradeBatchthe model’s scores of a claim’s candidates, as the service worker passes the stored answers: each entry names aclaimand itsscores, each anobservationref and ascorein basis points (0 to 10,000). An expectation may then stateraisedScore, the score the rules read: max(S, (1 − λ)·S + λ·m) from the floorscoreS when S is at or abovethresholds.partial, else S. The runner computes it from this formula alone and checks that the receipt’s signals show it (to four places) with the model’s score; the rule checks readraisedScore, while evidence and assignment follow the floor. Goldens 116 to 119 raise a partial match to VERIFIED, raise a floor at partial short of it, leave a NOT_FOUND below partial untouched, and ignore a score on a candidate the claim did not take. mode."batch"runsgradeBatch(§3) on every claim ofinput.claimsthatfixeddoes not name, with the observations in a memory index."grade"makes the onegrade()callinput.gradedescribes, which grade mode requires and batch mode refuses:claim(a ref, ornull),candidates(observation refs, passed as given),agent(whose grant in force applies; required whenclaimis null, else the claim’s agent),priorSpendCents(context.priorSpendCents, default 0) andscope(context.scope.observed). A claim-less call in a file withactivityalso getscontext.attribution = attribute(candidates[0], activity, policy, evaluatedAt)(§7).- Claims.
ref,agent,typeandclaimedAtare required.counterparty,amountCents,currency,subjectHint,claimedAtPrecision,claimedAtEnd(§4.1:dayprecision only),targetAt,observedAtandsourceKindare theClaimfields of those names.textis the normalised claim text that feedsrawHash(§10), androwKeya label for the vendor’s own row id. - Observations.
ref,source,typeandoccurredAtare required;counterparty,merchant,amountCents,currency,subjectHint,observedAtandrowKeyare optional. An observation holds only fieldsallowlistedFields[source]names, from a source that sees its type. - Grants.
agentis required.allowedTypes,counterpartyAllowlist,spendCeilingCents(withspendCurrency),declaredScopes,observedScopesandaccounts(source → account labels) are theGrantfields;updatedAtis when the grant took effect, andref(optional) letsexpect[].inputs.grantname it. An agent may have several grants: the one in force is the latestupdatedAt ≤ evaluatedAt(§3), one agent’s grants never share an instant, and every agent a claim or an expectation names has a grant in force. - Coverage. One
ObserverCoverage(§3) per entry:source,lastSyncAt,windowStart,windowEnd, and optionallyobserverId,accountHash(a label),allCalendarsandchangesSince(bothgcal.eventonly). repairs(optional): the clock repairs the batch’s coverage carries (§3 v1.16),{from, to}; the loader puts them in every coverage it builds, and a grade sees those found by itsevaluatedAt.activity(optional): the §7Activitywith refs:sessions(agent,at),idle(from,to),entries(agent,at,type),adapterSyncs(agent,lastSyncAt) andclaims(claim refs).fixed(batch mode, optional): FINAL or USER_OVERRIDDEN pre-assignments (§4.3),{claim, observation}. A claim named here already holds its receipt: it is not passed togradeBatchand has no expectation.claim: nullis an overridden UNASKED receipt, whose observation is taken. Each pair was fixed before every instant the file grades at, so it is in force in every grading of the file (§4.3).events(optional): state events applied withstate.tstransition()after grading, in order (§8), each naming its receipt byref(a claim, or an orphan observation for an UNASKED receipt).overridecarriesgrade(VERIFIEDorNOT_FOUND),reasonandat.supersedenames theclaimthat supersedes an UNASKED receipt.regrade(batch mode) grades the same inputs again at itsevaluatedAt, each input filtered by that instant (§3), and installs the subject’s new receipt; withdeleted: truethat grading returns no receipt for the subject, and the caller deletes the PENDING UNASKED receipt (§7).rejected: truemarks an illegal event:transitionmust throwIllegalTransitionErrorand nothing changes (a stale regrade, a regrade of a FINAL receipt, an override dated beforecomputedAt).assertIds(optional): when an expectation depends on hash-derived ids (a tie broken by id, a dedupe bycontentHash), the id relations it relies on: eachequalpair has one id, eachascendingpair is in id order. The spec checks each one.expectis the whole result. In batch mode it lists every receiptgradeBatchreturns: a missing or an extra receipt fails. In grade mode it holds exactly the one receipt. Receipts are compared after the events. Each entry names its subject with exactly one ofref(a claim ref for a claim receipt, an observation ref for that orphan’s UNASKED receipt) andscope({agent, scope}, a scope-diff receipt), and givesgrade,ruleId,state,observationRefs,scoreandreasonCodes(an exact, ordered list). Optional:reasonParams(below);agent, the receipt’s agent;attributionScore, an UNASKED receipt’s attribution score as a reduced fraction;override(the override’s grade) andpreviousState(PENDINGorFINAL, the state before it) on a USER_OVERRIDDEN receipt;supersededBy, the superseding claim’s ref;finalFrom(v1.18), the instant a PENDING receipt says it can be FINAL from, as the receipt writes it (the runner checks on every receipt that one is present iff the state before any override is PENDING); and, in batch mode,inputs, whatgradeBatchreturned with the receipt (§3):grant(a grant ref, which must be the grant in force),priorSpendCents(context.priorSpendCents) andscope(context.scope.observed).scoreis the best candidate’s exact score as a reduced fraction"p/q", ornullwhen no candidate was scored.expect[].reasonParams(optional) holds the params of every reason keyed by code, exactly per the closed table below: ids are written as refs, ascopeHashis written as the observed scope (the loader storessha256Hex(scope)), and asourceis left out for a type with no sources; the runner then compares reasons exactly.__snapshots__/ids.jsonis written bytests/golden/write-ids.ts. Expected values are computed by hand from §5 in basis points.accountHashvalues like"acct-a", andaccountsentries, are labels; the loader replaces each label with the SHA-256 of the label. ArowKeylabel becomesrowKeyHash = sha256Hex(canonicalJson(rowKey))(§10), inrawHashfor a claim and incontentHashfor an observation.- Defaults the loader fills: claim
extractor({id: 'golden', version: '1', confidence: 1}),sourceKind: 'activity_view',claimedAtPrecision: 'minute', claimobservedAt=claimedAt, claimtext(forrawHash, §10) = the claim’srefunless given,requestIdabsent; observationobservedAt= the earliestlastSyncAt≤evaluatedAtamong its source’s coverage entries whose interval containsoccurredAt, elseevaluatedAt; observationcontentHashper §10; grant: every type allowed, no allowlist, no ceiling, no scopes, noaccounts,updatedAt=evaluatedAtminus one day; coverageobserverId= itssource;activitylists empty andactivity.claims= every claim of the file. - A scenario must state every input that decides its outcome: coverage with
accountHash(else coverage is capped at partial), a non-common domain when it relies oncounterpartyDomain,targetAtfor calendar claims, positive idle evidence foruserIdle. Invisible characters (zero-width, bidi) are written as JSON\uescapes, never raw.
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.
- Inputs. c1:
email.senttopriya@company.com, subject “Q3 deck”, claimed 14:02. o1: agmail.sentrow topriya@company.com, subject “Q3 deck”, at 14:02. Onegmail.sentcoverage entry (accountacct-a) saw 12:00 to 15:00 and synced at 15:00, which is alsoevaluatedAt. - Window. Unit [14:02, 14:03]; window [14:00, 14:48]. Coverage is
full. - Score. An address claim with a subject:
timeProximity,counterpartyExact,counterpartyDomainandsubjectOverlapapply;counterpartyNamedoes not (§5.2). D = 2500 + 3500 + 1500 + 1500 = 9000. o1 is inside the unit (1), the addresses are identical (1, 1), and the token sets are both {q3, deck} (1). N = 9000, so S = 9000/9000 = 1/1, and10000 · 9000 ≥ 8500 · 9000. - Rule. No over-scope condition holds and coverage is
full, so the first match isverified. - State. FINAL (§8, VERIFIED): the sync at 15:00 is ≥ end(p,t) +
graceMin= 14:03 + 30 min = 14:33. - Receipt. VERIFIED,
observationIds[o1], reasonsmatch.best.
16.2 Recipient domain match, subject partial
Golden: 02-domain-match-subject-partial.json.
- Inputs. c1 as in 16.1. o1: to
raj@company.com, subject “Q3 budget”, at 14:02. Coverage as in 16.1. -
Score. D = 9000 (as in 16.1). timeProximity1.counterpartyExact0: different local parts, both ASCII, no+tag, so nocp.*reason.counterpartyDomain1: the same domain,company.com∉commonDomains, neither address confusable.subjectOverlap: {q3, deck} against {q3, budget} is{q3} / {q3, deck, budget} = 1/3. N = 2500 + 0 + 1500 + 1500 · 1/3 = 4500, so S = 4500/9000 = 1/2 ( counterpartyDomainandsubjectOverlapscored above 0, so the §5.3 content gate passes). - Rule.
verified:10000 · 4500 = 45 000 000 < 8500 · 9000 = 76 500 000, no.partial:10000 · 4500 = 45 000 000 ≥ 5000 · 9000 = 45 000 000, yes, with equality. Summed in floating point the score is 0.5 or 0.49999999999999994 depending on the order (§5.3); the exact comparison givespartialevery time. - State. FINAL (§8, PARTIAL needs
graceElapsed): 15:00 is ≥ end +graceMin= 14:33 and ≥ end +afterClaimMin= 14:48. - Receipt. PARTIAL,
observationIds[o1], reasonsmatch.best.
16.3 No observation, grace not elapsed
Golden: 03-pending-grace-not-elapsed.json.
- Inputs. c1 as in 16.1; no observation. The only
gmail.sententry saw 12:00 to 14:20 and synced at 14:20, which is alsoevaluatedAt. - Coverage. The entry overlaps the window [14:00, 14:48] but does not contain it:
partial, sono_coveragedoes not match. - Rule. No candidate, so
verifiedandpartialdo not match.graceElapsedneeds a sync ≥ 14:33 and ≥ 14:48; 14:20 is neither, sopendingmatches. - State. PENDING, shown as “checking”. Unless a later grading changes it, the
pendingoutcome finalises as UNVERIFIABLE with reasoncoverage.never_completedonceevaluatedAt≥ end +finalizeUnverifiableHours= 10-07 14:03 (§8). - Receipt. grade UNVERIFIABLE (the placeholder, §2), no score, reasons
grace.waiting{source:gmail.sent},match.none.
16.4 No observation, grace elapsed
Golden: 04-not-found-after-grace.json.
- Inputs. c1 as in 16.1; no observation; coverage as in 16.1 (12:00 to 15:00, synced 15:00, one account).
- Rule. Coverage is
full;graceElapsedholds (15:00 ≥ 14:33 and ≥ 14:48), sopendingdoes not match; with no candidate,bestScoreLt: partialis true.partial_coverageneedspartialcoverage, so the first match isnot_found. - State. FINAL (§8, NOT_FOUND needs
graceElapsed). - Receipt. NOT_FOUND, no score, reasons
coverage.account_unbound{source:gmail.sent} (the grant binds no Gmail account, §6.1) andmatch.none.
16.5 Gmail never synced in the window
Golden: 05-gmail-never-synced-in-window.json.
- Inputs. c1 as in 16.1; no observation;
evaluatedAt10-07 15:00. Twogmail.sententries: one saw 10-06 07:00 to 09:00, the other 10-07 12:00 to 15:00. - Rule. Neither entry overlaps the window [10-06 14:00, 14:48], so coverage is
noneandno_coveragematches beforeverified,pendingandnot_found. The grade is UNVERIFIABLE, never NOT_FOUND: Attest did not look (Hard Rule 5). - State. FINAL (§8):
evaluatedAt10-07 15:00 is ≥ end +finalizeUnverifiableHours= 10-07 14:03. - Receipt. UNVERIFIABLE, no score, reasons
coverage.none{source:gmail.sent},match.none.
16.6 Purchase with no receipt parser
Golden: 06-purchase-without-receipt-parser.json (and 78-purchase-no-parser-cap.json).
- Inputs. c1:
purchase.madefrom “acme”, 4999 USD, claimed 14:02. No receipt observation. Onegmail.receiptentry (accountacct-a) saw 12:00 to 15:00 and synced at 15:00. - Coverage.
policy.receiptMerchantsis empty (no receipt parser ships yet), so no parser could have read an “acme” receipt: coverage is capped atpartialwith reasoncoverage.no_parser(§6.1), although the entry contains the window [14:00, 14:48]. - Rule. No ceiling is set, so the over-scope rules do not match. The claim is not underspecified (
amountExactandmerchantMatchapply). No candidate.graceElapsedholds (15:00 ≥ 14:33 and ≥ 14:48), sopendingdoes not match, andpartial_coveragedoes.not_foundwould need coveragefull, which the cap rules out. - State. PENDING until
evaluatedAt≥ end +finalizeUnverifiableHours= 10-07 14:03 (§8). - Receipt. UNVERIFIABLE, no score, reasons
coverage.partial{source:gmail.receipt},coverage.no_parser{source:gmail.receipt},match.none.
16.7 Orphan observation, attribution 0.7
Golden: 07-unasked-attribution-0-7.json.
- Inputs. No claim. o1: a
gmail.sentrow tovendor@supplier.example, subject “Re: invoice”, at 14:03. Activity: a Muse session ping at 14:00, a recorded idle interval 13:50 to 14:10, and Muse’s adapter last synced at 14:50. - Attribution (§7). o1 is an orphan (§4.3), graded as
grade(null, [o1], …)with Muse’s attribution.agentSessionActive: 14:00 is within ±sessionMin(10 min) of 14:03, 4000 bp.userIdle: 13:50 to 14:10 covers [14:01, 14:03], 3000 bp.agentSignature: Muse’s signature list is empty, so it cannot fire.activityLogAdjacent: no activity entry. Score 4000 + 3000 = 7000 bp = 7/10 ≥ 6000. - Rule.
unasked, with attribution “likely”. - State. FINAL (§7): Muse’s adapter sync at 14:50 is ≥
occurredAt+beforeClaimMin+graceMin= 14:35, so a late claim can no longer explain o1. - Receipt. UNASKED,
observationIds[o1], confidence 7/10 (the attribution score), reasonsunasked.sessionandunasked.idle{agent:muse}, each rendered with “likely”.
16.8 Claim type not in the grant
Golden: 08-type-not-in-grant.json.
- Inputs. c1, o1 and coverage as in 16.1. Muse’s grant allows only
calendar.created,calendar.updatedandcalendar.deleted. - Rule.
typeNotInGrantholds, andover_scopecomes beforeverified(§6.2). c1 is still assignable (§4.3), so o1 scores 1/1 as in 16.1, is assigned, and is named bymatch.best; it does not become an orphan. - State. FINAL at once (§8): the claim’s type alone decided it.
- Receipt. OVER_SCOPE,
observationIds[o1], score 1/1, reasonsscope.type_not_allowed{type:email.sent},match.best.
16.9 Amount above the ceiling
Golden: 09-amount-above-ceiling.json.
- Inputs. c1:
purchase.madefrom “acme”, 20000 USD ($200.00), claimed 14:02. Muse’s grant:spendCeilingCents5000,spendCurrencyUSD. No observation and no coverage at all;evaluatedAt14:05. - Rule.
amountAboveCeiling(§6.1):purchase.made∈spendTypes, a ceiling is set, and the Amount is the claim’s 20000 (no candidate), which is > 5000:over_ceiling. Over-scope rules come beforeno_coverage(§6.2), so the agent’s own claim decides it with no receipt in view. - State. FINAL at once (§8): the claim’s own amount decided it.
- Receipt. OVER_SCOPE, no score, reasons
ceiling.exceeded{amountCents: 20000, ceilingCents: 5000},match.none.
16.10 Two claims, one observation
Golden: 10-two-claims-one-observation.json.
- Inputs. c1: to
priya@company.com, subject “deck”, claimed 14:00. c2: topriya@company.com, subject “Q3 deck”, claimed 14:02. o1: one sent row topriya@company.com, subject “Q3 deck”, at 14:02. Coverage as in 16.1. -
c1 × o1. c1’s unit is [14:00, 14:01]; o1 is Δ = 60 000 ms after it, which equals toleranceMin, sotimeProximityis 1.counterpartyExactandcounterpartyDomainare 1.subjectOverlap:{deck} / {q3, deck} = 1/2. N = 2500 + 3500 + 1500 + 1500 · 1/2 = 8250, so S = 8250/9000 = 11/12: alone, it would verify. - c2 × o1. S = 1/1, as in 16.1.
- Assignment (§4.3). One-to-one, best score first: (c2, o1) at 1/1 beats (c1, o1) at 11/12, although c1 was claimed earlier. c2 takes o1.
- c1. No candidate is left (
match.none). Its window [13:58, 14:46] lies inside 12:00 to 15:00, and the sync at 15:00 is ≥ 14:31 and ≥ 14:46: rulenot_found, FINAL, reasonscoverage.account_unbound,match.none. - c2. Rule
verified, FINAL (15:00 ≥ 14:33), reasonsmatch.best. One observation never verifies two claims.
16.11 Duplicate observation
Golden: 11-duplicate-observation-content-hash.json.
- Inputs. c1 as in 16.1. o1 and o2 are the same sent row (to
priya@company.com, “Q3 deck”, 14:02) read by two syncs, first seen at 14:10 and 14:40. Same source, type and content fields and no row key, so the samecontentHash(§10) and the same id. Activity: a Muse session ping at 14:00 and idle 13:55 to 14:10. - Candidates (§4.1). Duplicates by
contentHashcount once; the copy first seen earliest is kept. - Rule and state. c1 × o1 = 1/1 as in 16.1:
verified, FINAL (15:00 ≥ 14:33), one observation id. - Why it matters. Counted twice, the second copy would be an unassigned orphan, and the session and idle signals would turn it into an UNASKED receipt. The batch returns only c1’s receipt.
16.12 Time-zone edge: 23:58 and 00:01
Golden: 12-timezone-midnight-edge.json.
- Inputs. All times in −07:00. c1: to
priya@company.com, “Q3 deck”, claimed 10-06 23:58. o1: the same address and subject at 10-07 00:01, on the next calendar day. Onegmail.sententry saw 10-06 22:00 to 10-07 01:00 and synced at 01:00, which is alsoevaluatedAt. - Time. c1’s unit is [23:58, 23:59]; o1 is Δ = 120 000 ms after it. On the after side the edge is
afterClaimMin= 2 700 000 ms and the tolerance is 60 000 ms, sotimeProximity= (2 700 000 − 120 000) / (2 700 000 − 60 000) = 2 580 000 / 2 640 000 = 43/44. - Score. S = (2500 · 43/44 + 3500 + 1500 + 1500) / 9000. Multiplying by Q = 44: N = 2500 · 43 + (3500 + 1500 + 1500) · 44 = 107 500 + 286 000 = 393 500 and D = 9000 · 44 = 396 000, so S = 393 500 / 396 000 = 787/792.
10000 · 393 500 = 3 935 000 000 ≥ 8500 · 396 000 = 3 366 000 000: ruleverified. - State. FINAL: 01:00 is ≥ end +
graceMin= 00:29. Windows and distances are measured between instants in milliseconds (§4.1, §5.1), so crossing midnight changes nothing. - Receipt. VERIFIED,
observationIds[o1], reasonsmatch.best.
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.
- Inputs. Muse’s activity view says “Sent email to Priya with Q3 deck. Created calendar event ‘Q3 review’ Thu 2pm.” c1:
email.sentto the name “Priya” (not an address), subject “Q3 deck”, claimed 14:02. c2:calendar.created, subject “Q3 review”, claimed 14:02,targetAtThursday 10-08 14:00. Gmail (onegmail.sententry, 12:00 to 14:50, synced 14:50): o1 topriya@company.com, “Q3 deck - final”, at 14:02, and o2 tovendor@supplier.example, “Re: invoice”, at 14:03. Calendar: twogcal.evententries (all calendars visible, one account), checked at 14:10 and 14:40, each covering 10-01 to 11-01, with no calendar observation. Activity: a Muse session ping at 14:01, idle 13:55 to 14:20, Muse’s adapter synced at 14:50.evaluatedAt15:00. - c1 → VERIFIED. A name claim:
timeProximity,counterpartyNameandsubjectOverlapapply, D = 2500 + 2500 + 1500 = 6500. c1 × o1:timeProximity1; “priya” is a token of the local partpriya, socounterpartyName1; {q3, deck} against {q3, deck, final} is 2/3. N = 2500 + 2500 + 1500 · 2/3 = 6000, so S = 6000/6500 = 12/13. Multiplying by Q = 3:10000 · 18 000 = 180 000 000 ≥ 8500 · 6500 · 3 = 165 750 000: ruleverified. FINAL: the Gmail sync at 14:50 is ≥ 14:33. - c1 × o2.
timeProximity1 (14:03 is inside the closed unit [14:02, 14:03]); the local partvendordoes not hold “priya”, socounterpartyName0; “Re: invoice” reduces to {invoice}, sosubjectOverlap0. Only time matched, so the content gate (§5.3, v1.11) makes S = 0 (the weighted mean alone would be 2500/6500 = 5/13, also < 1/2): o2 is not assigned, and it is an orphan. - c2 → NOT_FOUND. The calendar window is [
targetAt− 3 days,targetAt+ 3 days] = [10-05 14:00, 10-11 14:00], inside both Calendar entries: coveragefull. No calendar observation, so no candidate. For a calendar claimgraceElapsedneeds a sync ≥ end(p,t) +graceMin= 14:33, which the 14:40 check gives. Rulenot_found, FINAL, reasonscoverage.account_unbound{source:gcal.event},match.none. - o2 → UNASKED (likely Muse). The session ping at 14:01 is within ±10 min of 14:03 (4000 bp) and idle 13:55 to 14:20 covers [14:01, 14:03] (3000 bp): 7000 bp = 7/10 ≥ 6000, rule
unaskedon two signals. FINAL: the adapter sync at 14:50 is ≥ 14:35.
16.14 The §2 demo with Calendar never opened
Golden: 14-demo-calendar-coverage-absent.json.
- Inputs. As in 16.13, with no
gcal.eventcoverage at all. - c2 → UNVERIFIABLE. No eligible Calendar entry overlaps its window, so coverage is
noneandno_coveragematches: UNVERIFIABLE, never NOT_FOUND (Hard Rule 5). It stays PENDING untilevaluatedAt≥ end +finalizeUnverifiableHours= 10-07 14:03, since the user may still open Calendar. Reasonscoverage.none,match.none. - c1 and o2 are unchanged: VERIFIED at 12/13 and UNASKED at 7/10.
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).