Adapters and observers

Who this is for: whoever adds, repairs or reviews an Attest observer or adapter: the code that reads a vendor page the user has open and turns it into facts. What you’ll know after: the observer contract, what one pass posts and in what order, what every DEGRADED reason code means, how a page becomes a labelled, versioned fixture, and how to repair a selector that broke.

Status: v1.2 (Attest 0.1.0; v1.1 was Sprint 3.0, which added §10, the agent adapter skeleton and its claim patterns; v1 was Sprint 2, task 2.9). It covers the three observers that ship (gmail.sent, gmail.receipt, gcal.event), the agent adapters, whose readers ship off under their vendors’ terms so that an agent’s activity reaches Attest as a pasted log (Decisions [3.10]), and the fixture capture tool. The accuracy figures in RESULTS.md are measured on the founder’s real captures, faked with the capture key before they were committed, and on hand-built pages: 4 real Gmail Sent pages (gmailSentRealFixtures), 6 real receipt emails (receiptRealFixtures), and 18 Calendar pages of which 8 are hand-built (gcalFixtures, gcalSyntheticFixtures). The live smoke ran on 2026-10-07 (sprints/sprint_2.md 2.8).

What and how the ledger stores what these observers send is in docs/privacy-architecture.md. How a receipt is graded from it is in docs/grading.md.

Contents

  1. What an observer cannot see
  2. The observer contract
  3. One pass: what runs, what is posted and in what order, the acknowledgements, and when no coverage is posted by design
  4. Reading a DEGRADED reason: every reason code
  5. selectors.json and versions
  6. The fixture loop: capture, label, test, version
  7. Repairing a broken selector
  8. The observers: gmail.sent, gmail.receipt, gcal.event
  9. Message bus
  10. Agent adapters: the contract, one pass, how a sentence becomes a claim, and the skeleton’s reasons

1. What an observer cannot see

Limitations first (Hard Rule 5: Attest never says “all clear” about something it could not see).

2. The observer contract

The contract is extension/src/observers/types.ts. An observer is build_spec.md §7’s Adapter<RawRow> plus what coverage needs. One observer reads one source and writes one coverage stream; a content script that reads two sources (Gmail’s Sent list and an opened receipt) runs two observers.

Member What it is
id, vendor, version Short tokens (ComponentId: letters, digits, ., _, +, -; no space, no @). version is the observer’s selectors.json version (§5).
hosts Exact match patterns, a subset of OBSERVER_HOSTS in src/config.ts: https://mail.google.com/* or https://calendar.google.com/*. Never a wildcard (Hard Rule 8).
source gmail.sent, gmail.receipt or gcal.event.
health(doc, ctx) Runs first, every pass: OK; IDLE with 1 to 32 reason codes when the page is not one the observer reads (another Gmail folder, no conversation open: neither OK nor broken); or DEGRADED with 1 to 32 reason codes. IDLE and DEGRADED mean nothing is extracted and no coverage is written. ctx is the pass’s (below); most observers read only the page, and the Calendar one times its run of busy passes with it, so the run goes on while the pass is DEGRADED page.busy (§4.4).
extract(doc, ctx) The raw rows the page shows (RawRow, below). Pure over the DOM. ctx.observedAt is the pass instant, ctx.offsetAt the browser’s UTC offset function.
account(doc) The signed-in account’s address as the page shows it, or null. Only its hash leaves (§2.2).
visibleWindow(doc, ctx) or visibleWindows(doc, ctx) Exactly one of the two. What the pass saw whole, as ISO 8601 instants with an offset. visibleWindows may answer []: a clean pass that asserts no coverage by design. Null: the observer should know and cannot, so the pass is DEGRADED window.unknown.
allCalendars(doc) gcal.event only: true when every calendar the page lists is shown. Null is DEGRADED calendars.unknown.
snapshot(doc, ctx) gcal.event only: the range’s snapshot (snapshot.ts), or null while the grid is still drawing.
attributes (optional) Every attribute a read of the page depends on: the ones its selectors match on (selectorAttributes in batch.ts) and the ones it reads. The content script’s one watcher observes only these and class, hidden, style; absent, every attribute is watched. Each Gmail and Calendar observer names its own, and a test changes every attribute it does not name on every fixture page and finds no read changed.

assertObserver (base.ts) checks all of this when the content script starts, before any page is read, and throws ObserverError('invalid_observer') on a bad definition: a developer error fails loud.

Every member is pure over the DOM and read-only (Hard Rule 3): it reads attributes, text and computed style, and never writes the DOM, dispatches an event, focuses, scrolls, navigates or fetches. The lint rule attest/no-act covers all of extension/src/observers/** and extension/entrypoints/content/**, and tests/e2e/no-input-events.spec.ts runs the shipped content scripts on the fixtures and records zero input events. Text on a vendor page is data: it never becomes a selector, a policy, a message type or an instruction (agents/adapter_engineer.md rule 6).

2.1 Raw rows and redaction

A RawRow is { type, rowKey?, fields }. type must be one the policy reads from this observer’s source (policy.sourcesByType); rowKey is the vendor’s own row id when the page shows one; fields holds the row’s content as plain own data properties.

redact.project() (src/core/redact.ts) turns a raw row into an Observation. It keeps only the fields policy.allowlistedFields[source] names (policy/default.json), computes id, source, type, observedAt, rowKeyHash and contentHash itself, and refuses a row whose allowlisted value fails its schema rather than cut or drop it. Anything else in fields never leaves the content script (Hard Rule 2). What each allowlisted field holds is described per source in docs/privacy-architecture.md §4.

Two rules every extractor follows:

2.2 The account hash

Coverage belongs to one account, because grading counts accounts per source and checks them against the grant (grading.md §6.1). The address never leaves the content script: accountHashOf (src/observers/account.ts) reads it through normalize.email (display name and angle brackets dropped), ASCII-lowercases it, and sends the SHA-256 of that text. So Priya@Example.com in Gmail and priya@example.com in Calendar are one account, while a lookalike address hashes as another.

3. One pass

3.1 When a pass runs

A content script does little more than startObservers(observers, { post: postAcked }). The Gmail one starts its two observers together, so the tab has one MutationObserver, one activity tracker and one page-sync tracker for both; the Calendar one (gcal.content.ts) also adds the hash of the account its pass read to that pass’s calendar.snapshot (gcal/script.ts); the Calendar observer itself posts no rows of its own on a page whose account is known (§8.3). The skeleton (base.ts) watches the page’s DOM (the tree, the text, and only the attributes its observers read plus class, hidden and style, so a hover or focus change that touches only other attributes starts no pass; one that changes class or tabindex still does, and how often real pages are read is measured in Sprint 6.4) and runs a pass OBSERVER_DEBOUNCE_MS after the last mutation of a burst, never later than OBSERVER_MAX_WAIT_MS after its first (both in src/config.ts). The first pass runs one debounce after start, or, on a prerendered page, one debounce after the user opens it. A visible Calendar tab is also read at least once a minute (OBSERVER_HEARTBEAT_MS) and again whenever it becomes visible, so a quiet tab’s comparison base stays fresh (§8.3); a hidden tab is read when its page changes, and once more when the page comes back online, resumes or wakes (§3.5); a Gmail tab has no heartbeat. Passes of one observer run one at a time (serial.ts). The skeleton stops only when the extension context is gone (a post refused context_invalidated, or no runtime id), never on WXT’s ctx.onInvalidated, which the page itself can fire.

3.2 What a pass decides

Each pass reads the clock once (runPass in base.ts): that instant is the pass instant. planPass (plan.ts) then runs, over one synchronous read of the page, health() → extract() → project() on each row → the windows, allCalendars(), the snapshot and account() (coverage.ts). It never throws; every failure is a reason code. The result is one of:

Plan Health posted Rows posted Coverage posted
OK OK every row project() accepted one coverage.ping per window, and the Calendar snapshot when the account is known
OK_NO_COVERAGE OK every row project() accepted none, by design (§3.5); a Calendar snapshot is still posted when the account is known
IDLE IDLE, with reasons: the page is not one the observer reads (§4) none none
DEGRADED DEGRADED, with reasons every row project() accepted (none when health() or extract() failed) none

Every row goes through project(), and a row it refused does not stop the rows it accepted from being posted: they are evidence. It does void the pass’s coverage, because a refused row is not evidence of absence (Hard Rule 5). The pass still reads its windows after a refusal, so the health report names every reason (redact.missing_occurred_at and window.unknown together, for example).

3.3 What a pass posts, in order

Each message is posted only after the service worker answered the previous one:

  1. adapter.health: OK, IDLE or DEGRADED with reason codes, stamped with the observer’s id, version and the pass instant. Every pass reports its health (Hard Rule 6); an IDLE pass posts nothing else.
  2. observer.observations: the accepted rows, at most 500 per message, each with observedAt = the pass instant. Never sightedAt: only the ledger sets that (grading.md §10).
  3. coverage.ping, one per window the pass saw, each naming the observer’s source, the account hash, lastSyncAt = the pass instant, the window, allCalendars for Calendar, and userActive (whether the user pressed a key, pointed, scrolled or touched in the tab in the last minute; it feeds Sprint 3.5 activity, never coverage). An observer never sends changesSince: the ledger sets it (§8.3).
  4. calendar.snapshot: the Calendar observer’s snapshot of the range it read, after the coverage, only for a clean pass whose account is known.

The content scripts post through postAcked (src/bus/send.ts), which rejects when the service worker answers { ok: false, code } or the message never arrives. A rejected post ends the pass: nothing after it is sent, so coverage never follows rows that were not stored. If any message of the pass fails its bus schema (a bug), the pass posts only a DEGRADED message.invalid health report.

3.4 Acknowledgements

The service worker answers every adapter.health, observer.observations, coverage.ping and calendar.snapshot with a BusAck (src/bus/messages.ts): { ok: true } once it stored what the message carries, or { ok: false, code } with one closed code.

Code When
invalid_input A row the ledger refused (a field outside its source’s allowlist, a self-chosen contentHash, a sightedAt, an unknown observer, a source the observer does not read); a ping that cannot be what it says (a Gmail window ending after its sync, a Sent window not ending at its sync, a receipt window wider than one instant, a Calendar window shorter than a day less an hour or longer than 42 days and an hour); a snapshot that disagrees with its own pass’s pings; a health report naming an observer the extension does not have; and any observer message from a page that is not on that observer’s site (sender-site.ts: logged observer.wrong_site, and the named observer is not turned DEGRADED, since another site’s page cannot vouch for its health either way).
id_conflict A different fact under a contentHash the ledger already holds.
late_sighting A sighting of a stored row earlier than the stored one (a second tab’s pass that landed late); a Calendar snapshot read at or before the stored one of its range.
refused Coverage, or a snapshot, of a pass whose rows were refused.
clock_behind A row sighted, a ping synced, a snapshot read or a health report checked after the service worker’s clock: the clock went back between the read and the answer. Nothing of the message is stored and the observer is not DEGRADED; its next pass is read on the clock as it is (§3.6). Logged as a warning, observer.clock_behind.
failed The service worker could not store it (the ledger did not open, or a bug such as a policy object loadPolicy did not return). Logged at error as bus.handler_failed.
reader_off The person turned this observer’s page reader off in the options page (sprint_4 4.5; src/background/readers.ts): a tab opened before keeps the script it had until it is reloaded. Nothing of the message is stored, the observer is not DEGRADED, and the content script stops its observers (postAcked rejects with BusError('reader_off'); src/observers/base.ts). Logged at debug, observer.reader_off.
locked The ledger is encrypted and locked (sprint_6 6.5; src/background/locked.ts): every message of every observer is answered this way, health first, so nothing of the pass is stored, its coverage least of all, and a claim in its window stays UNVERIFIABLE, never NOT_FOUND (Hard Rule 5). The pass stops as for refused; the observer is not stopped, and reads again on its next pass after the person unlocks. The popup says the ledger is locked. A pasted import (claims.import) is answered locked too.

A Gmail message with several refusals reports the worst, in the order invalid_input, id_conflict, late_sighting. A Calendar pass, and a Calendar message from a page with no account, is stored all or none and answers the first refusal’s code (§3.6). A refused row turns the observer DEGRADED with a ledger.<code> reason (§4.5); a late Calendar snapshot and a message answered clock_behind do not, because nothing is wrong with the page.

3.5 When no coverage is posted by design

These passes are healthy (OK, never DEGRADED) and post no coverage, because the page does not show a span Attest could vouch for:

In each case the observer’s visibleWindows() answers []. An answer of null is different: it means the observer should know what it saw and cannot, and the pass is DEGRADED window.unknown.

Separately, the skeleton posts no coverage for a page that is offline or catching up (lifecycle.ts): while the browser reports the page offline, and after it comes back (online again, resumed after the tab was frozen, shown again after 5 minutes hidden, or awake after the machine slept) for each observer’s first pass and every pass within 30 seconds (PAGE_RESYNC_MS). Such a pass posts its health (OK) and its rows, but no coverage ping and no Calendar snapshot. When the 30 seconds are up the skeleton asks for a pass, so coverage resumes on a quiet page. A page that is online but cannot reach its server still reports itself online (attacks-that-work.md).

3.6 What the service worker does with a pass

The handlers are in src/background/observer-messages.ts, src/background/calendar-pass.ts and src/background/calendar-rows.ts. One observer’s messages are handled one at a time, in arrival order, whichever tab sent them. A ping is recorded as a coverage interval (src/ledger/coverage.ts) and kept as the latest ping of its observer and account.

First-look markers. A Calendar row carries no account and no pass. Without a marker, an event that was already on the calendar could sit inside a span that another view (another account, or the same account’s other range) was following for changes. It would then read as a change Attest saw happen, and could be reported as UNASKED (Hard Rule 7). So the ledger stores every first sighting with a first-look marker, in the same transaction as the row. A first sighting is an event its pass could not compare with an earlier view: the first look at a range, a calendar switched on, a day that was hidden, an event that began before the visible range, or any row from a page with no account. A creation or an update that another range’s stored view (any account) already held in its new state, read after this range’s base and before this pass, is one too: the change happened before that read, not at this pass (calendar-seen.ts). So is a change noticed late: a creation or an update whose comparison base (the range’s stored snapshot) was read more than the narrowest attribution window before this pass, and a deletion whose first absence was compared with such a base (calendar-step.ts staleChangeMs: the smallest of unasked.windows sessionMin, idleMin and adjacentMin, two minutes under the shipped policy). Attribution reads a change’s first sighting (grading.md §7); a change made at any time since an older base could lie outside the windows it reads there, and whoever was active when Attest looked again would be named for it. The marker is a gcal.event coverage entry:

Field Value
observer and account those of the pass (none for a page without an account)
window the row’s occurredAt, a single instant
lastSyncAt and changesSince the row’s first sighting
allCalendars true only if every calendar the pass listed was shown and named once

The orphan rule’s part (b) reads it as a view that found the row already there, so the row is never an orphan. This holds even when the pass’s windows left out the event’s day or its start, or when the pass recorded no window at all. A marker never makes a row an orphan through part (a), because it compared nothing before its own sync. It never makes coverage full either: a single instant never contains a claim window. A marker is never merged with a pass’s window (in coverage-merge.ts it is a merge group of its own). Only a row the ledger inserted gets a marker: a re-sighting of a stored row changes nothing.

4. Reading a DEGRADED reason

Each observer’s latest health report is one row of the adapterHealth table: adapterId, version, status, reasons, checkedAt. While any is DEGRADED, the popup and the side panel show the degraded banner at their top (Sprint 4.1, 4.2: src/ui/DegradedBanner.tsx): which reader, its version, what it means, and a link to a new GitHub issue filled in with the reader’s id and version and the Chrome major version, nothing else (extension/tests/e2e/degraded-banner.spec.ts). The banner names no reason code; to read the reasons, ask from the DevTools console of the extension’s options page (an extension page may ask the service worker; a content script may not):

chrome.runtime.sendMessage({ type: 'ui.query', query: { kind: 'health.list' } }, (result) =>
  console.log(JSON.stringify(result.data, null, 2)),
);

A reason is a code, never page text (Hard Rules 2, 10). How to read one:

A DEGRADED pass writes no coverage, so whatever it could not see grades as UNVERIFIABLE, never as NOT FOUND.

4.1 Pass reasons

Any observer can give these (plan.ts PASS_REASONS).

Code Meaning
health.threw health() threw. A bug in the observer.
health.invalid health() answered something that is not OK, or IDLE or DEGRADED with 1 to 32 reason codes (a sentence, page text). A bug.
extract.threw extract() threw. For receipts: a message a parser claimed has a date that names no single minute (ReceiptError('time_unresolved')), so no time is guessed. For Calendar: the page changed between health() and extract(), or a stored snapshot is of another range (GcalError).
extract.invalid extract() returned something that is not a list of raw rows. None of its rows is trusted.
extract.foreign_type A row of a type the policy does not read from this observer’s source (policy.sourcesByType).
redact.invalid_field project() refused a row: an allowlisted value failed its schema (a name on two lines, a merchant over 120 characters, a number written as text), or was held by a getter or a prototype.
redact.missing_occurred_at A row with no valid occurredAt. Gmail Sent: a row whose time names only a day (Oct 1), or a row that holds more than one message.
redact.invalid_argument A bad source or row context (a rowKey that is not a string). A bug.
redact.threw project() threw something other than its own RedactError. A bug.
account.threw account() threw.
account.invalid account() returned something that is not one email address (on Calendar, an account switcher whose label holds no address in brackets).
window.threw The window reader threw.
window.unknown The observer should know what it saw and cannot: a Sent head with a row whose time is unknown, or a Calendar page whose “N more” control sits in no day, so its layout is unknown.
window.invalid A window that is not two ISO instants with an offset, or ends before it starts.
window.future A Gmail window that ends after the pass instant. A list of what already happened cannot have been seen past the moment it was read. A receipt opened seconds after it arrived, on a device whose clock runs behind Google’s, can show this until the next pass.
calendars.threw allCalendars() threw.
calendars.unknown The calendar list could not be read, so a calendar switched off could not be told from a deleted event.
calendars.invalid allCalendars() answered something that is not true or false.
snapshot.threw The Calendar snapshot reader threw. No coverage and no snapshot.
message.invalid A message of this pass failed its bus schema. The pass posts only this health report. A bug.

clock.invalid (the clock gave no ISO instant) is never posted: without an instant there is no health report to stamp. It appears only in the pass’s own result, for tests.

4.2 gmail.sent reasons

From sent-page.ts and health.ts (SENT_HEALTH_REASONS).

Code Meaning
location.not_gmail The page address is not on https://mail.google.com.
not_sent_folder IDLE: the view is not the Sent list (Drafts, Inbox, a label, a search, settings).
not_sent_list IDLE: Sent with a conversation open, or the preview pane showing one.
view.mismatch The route says Sent but the nav’s selected folder or the document title names another folder: Gmail is between views and may still show the previous one, or the page changed so that a Sent list reads as another folder. DEGRADED, so one that lasts is loud (sprint_2 2.11; not_sent_folder before gmail.sent 1.4.0).
selector.missing.main, selector.ambiguous.main No shown role="main", or two.
selector.missing.grid, selector.ambiguous.grid No shown role="grid" in it, or two.
selector.missing.row The grid has no rows. An empty Sent folder cannot be told from a list still loading, so neither is read as “nothing sent”.
selector.missing.subject, selector.ambiguous.subject A row without one subject anchor carrying data-legacy-last-message-id, or with two.
selector.missing.cell The subject anchor is not inside one of the row’s grid cells.
selector.missing.recipient No recipient element (email or data-hovercard-id) in a cell before the subject.
selector.missing.time No element with a title or aria-label in a cell after the subject.
row.recipient_invalid A recipient’s email attribute (else title) is not an address. The markup changed; the observer does not guess which attribute is right.
selector.missing.account, selector.ambiguous.account No account address in the banner, or two different ones (an account chooser is open).

A row that cannot be read structurally makes the whole page DEGRADED: skipping it would report its send as absent (Hard Rule 5).

4.3 gmail.receipt reasons

From receipts/health.ts (RECEIPT_HEALTH_REASONS). Only an opened conversation is read, and only it can be DEGRADED; every other view is IDLE.

Code Meaning
no_conversation IDLE: no conversation is open (a list, settings, anything else). Nothing to read, neither OK nor broken (sprint_2 2.11; OK before gmail.receipt 1.5.0).
selector.missing.main The address names a conversation and the page shows no role="main".
selector.ambiguous.main A conversation is open and Gmail shows two role="main": which one the user sees cannot be told.
selector.missing.subject No subject heading (h2[data-legacy-thread-id]).
selector.missing.message No message items (role="listitem" with aria-expanded; the message key data-legacy-message-id sits on the item or, as real Gmail draws it, on an element inside it).
selector.missing.sender An expanded message without its sender element.
selector.missing.date An expanded message without its date cell.
selector.ambiguous.account A conversation is open and the banner names two different accounts (an account chooser is open): whose mail is the user’s own, and whose coverage this is, cannot be told.
receipt.amount_missing A platform parser recognises a receipt (its sender and subject kind) whose amount is required, and finds no total line.
receipt.amount_ambiguous Lines with the chosen total label disagree. The total is not guessed.

The generic parser never makes a pass DEGRADED: mail it does not accept is simply not a receipt, and so is a stranger’s message whose display name, subject or amount a row could not carry, or whose date names no single minute.

4.4 gcal.event reasons

From gcal/read.ts (GCAL_REASONS). A Calendar page that is still drawing is OK and not read, until it has looked busy for too long (page.busy).

Code Meaning
selector.missing.main, selector.ambiguous.main No shown role="main", or two.
selector.missing.grid No shown grid in it (the schedule view has none).
selector.missing.calendar_list No calendar list in the side panel, so a calendar switched off could not be told from a deleted event.
range.invalid No visible day, a day key that is not a date, or days that are not consecutive.
range.too_long More than six weeks of days (a year view draws a grid per month and no events).
time_zone.mismatch The page’s GMT-04-style hint matches the browser’s offset on none of the visible days. Calendar writes times in its own zone setting, and its daylight-saving rules are not on the page. Change Calendar’s zone or the browser’s.
time_zone.ambiguous Two zone hints (Calendar’s secondary time zone) and only one matches the browser. Labels are written in the primary zone, which the page does not mark.
event.unreadable An event chip whose label is not in a shape the reader knows, whose chips disagree, that sits in no visible day, or whose label starts after every day its chips sit in.
event.calendar_hidden A chip on a calendar the list says is off: the list cannot be trusted.
event.time_ambiguous A start or end that a daylight-saving change skips or repeats.
event.too_many More than 500 events (the chips of one event count once): more than one bus batch can carry.
page.busy The page has looked busy (aria-busy="true", or a progress bar in the main region) through 5 minutes of passes, none more than two minutes after the one before: nothing is read, and a page that never stops looking busy is never OK with no coverage (Hard Rule 6). A pass that finds the page drawn clears it.

One unreadable chip makes the whole pass DEGRADED: no rows and no coverage. A refused chip is not evidence that an event is absent.

4.5 Reasons the service worker sets

When the ledger refuses a row a pass sent, or a Calendar pass holds an update back, the service worker turns that observer DEGRADED itself, with ledger.<code> reasons (src/ledger/health.ts):

Code Meaning
ledger.invalid_input A row, ping or snapshot the service worker refused as malformed or impossible (§3.4).
ledger.id_conflict A row that differs from the stored row with the same contentHash.
ledger.late_sighting A row sighted earlier than the stored copy of it. The stored row is unchanged: a batch may already have graded with it (grading.md §10).
ledger.event_churn A Calendar event was updated more than 20 times in a day: its further calendar.updated rows that day were not stored. The pass’s other rows, its snapshot and its windows (without changesSince) were stored, and it was answered {ok: true} (grading.md §3, Update rows per event).

Such a report stays until a later pass of the same observer has a message fully stored with no refusal. An OK report from the observer in the meantime does not clear it, because its rows have not been accepted yet. A DEGRADED report from the observer is stored with the ledger.* reasons kept beside its own. A pass that posts only its health clears nothing.

5. selectors.json and versions

Each observer keeps every selector, attribute name and label word it reads in one versioned file, checked with strict Zod when its module loads:

Rules (agents/adapter_engineer.md rule 3):

6. The fixture loop: capture, label, test, version

Every observer is written and tested against sanitised saved pages in extension/tests/fixtures/pages/<vendor>/<yyyy-mm-dd>/, never against a live page (agents/adapter_engineer.md rule 1). The loop:

  1. Capture the page from your own Chrome with pnpm capture (§6.1, §6.2).
  2. Review the output yourself before committing it (§6.3, §6.4).
  3. Label it: what the observer must produce from it (§6.5).
  4. Test and measure: the observer’s tests, the label checks and the bench (§6.6).
  5. Version and record: bump the selectors version and write the Decisions.md entry (§6.7).

6.1 Start a capture-ready Chrome

The capture tool attaches to a Chrome you are already running, over the Chrome DevTools Protocol on 127.0.0.1.

Chrome 136 and later refuse --remote-debugging-port on your default profile. Use a dedicated profile folder, and sign in to the vendor in it once:

# macOS
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --remote-debugging-port=9222 \
  --user-data-dir="$HOME/.attest-capture-profile"

# Linux
google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.attest-capture-profile"

Then, in that window:

  1. Sign in to the vendor (first time only; the profile remembers it).
  2. Open the exact page you want as a fixture, scrolled to what the observer needs.
  3. Close any open compose window or draft. Text you are typing into an editor is part of the page and would be captured.
  4. Close tabs you do not need. Fewer tabs means less chance of capturing the wrong one.

While the debugging port is open, any program on this machine can control that Chrome profile. Quit that Chrome when you are done capturing.

6.2 Capture

From the repo root (or from extension/):

# The first web page open in that Chrome
pnpm capture --vendor muse --name activity

# A specific tab, with the real names and free-text cells you know are on the page
pnpm capture --vendor gmail --name sent-list \
  --tab mail.google.com \
  --names "Priya Raman,Daniel Okafor" \
  --redact-selector '<css for the subject cell>' \
  --redact-selector '<css for the snippet cell>'
Option Default What it does
--vendor, --name required Folder and file name. Lowercase letters, digits and - only. Observer fixtures go under gmail or gcal: the label checks read only those two folders.
--tab <text> first web page Capture the one tab whose URL contains <text>. Fails if none or several match.
--names <a,b> none Real names to replace everywhere, whole and word by word, in any case.
--redact-selector <css> none Replace the text and labels (title, aria-label, alt, …) of matching elements with fake phrases. Repeatable.
--seed <secret> the capture key Key for every fake value and for which secrets are injected; same key, same fixture. Left out, the capture key file is used (~/.config/attest/capture-key: 32 random bytes made on first use, owner-only, never printed or committed). A seed given here must be a secret of at least 32 characters: a public word is refused (security review SEC-L2).
--date <yyyy-mm-dd> today (local) The date folder.
--cdp <url> http://127.0.0.1:9222 Must be on this machine.
--out-root <dir> extension/tests/fixtures/pages Where fixtures go.
--keep-selector <css> none Areas a redacted text is never replaced in elsewhere: a redacted text also replaces the same text anywhere on the page, which turns a hidden summary’s dates into every row’s date. Names, emails, amounts and addresses are still replaced inside. Repeatable.
--force off Overwrite an existing fixture and its labels.json entry. Without it the tool refuses.

It writes extension/tests/fixtures/pages/<vendor>/<yyyy-mm-dd>/<name>.html and adds an entry to labels.json beside it (other entries are left untouched):

{ "<name>.html": { "capturedAt": "<yyyy-mm-dd>", "selectorsVersion": null, "seededSecrets": ["…", "…", "…"], "expected": [], "notes": "" } }

The tool prints the tab’s address (without its query or fragment), the output path, and counts of what it replaced and removed. It never prints page content.

What the tool does to your browser

Nothing you can see. It attaches without Playwright’s default overrides (no focus or media emulation), reads one tab, and disconnects without closing anything. In the page it reads the DOM and computed styles and builds the fixture from a copy in an inert document. The copy carries each element’s critical computed styles inline (display and visibility, which every reader’s shown-check uses, plus position, colours and flex or grid layout) and none of the page’s own stylesheets: a real Gmail page carries about 4 MB of them, from which a test DOM takes over a second to compute one element’s style (capture of 2026-10-06). It never clicks, types, scrolls, focuses, navigates or changes the live page. tests/e2e/capture-tool.spec.ts checks this on every run: the live page must be byte-identical afterwards, with zero DOM mutations, zero events and zero network requests.

What the sanitiser does

extension/tests/tools/sanitize.ts:

6.3 Review before you commit (founder checklist)

From agents/adapter_engineer.md, plus two mechanical checks:

cd extension && pnpm exec tsx tests/tools/serve-fixtures.ts
# then open http://127.0.0.1:4173/<vendor>/<yyyy-mm-dd>/<name>.html

If anything personal is left, re-capture with more --names or --redact-selector and --force. Do not hand-edit a fixture to fix it, because the next capture would bring it back.

Re-faking a committed capture. A capture faked with the public seed is re-faked without its originals by capture-refake.ts: the value rules run again over the committed page with the capture key, so every amount, card number or ending, phone number, street address, address block, labelled id or code, Meet code and attribute UUID becomes a keyed fake of its old fake, the same everywhere on the page. Emails, names and redacted phrases stay (their old fakes only confirm a guess), and so do the seeded secrets. --also <value> names a value no rule finds (a number whose label the capture already faked), and --record <file> writes what replaced what, outside the repository only (the old fakes in it are reversible). Then update the labels from the record and run the fixture tests:

cd extension && pnpm exec tsx tests/tools/capture-refake.ts --record "$TMPDIR/refake.json" <fixture.html>…

The emails, names and phrases are re-faked by capture-refake-people.ts, found by the faker’s own shapes (two pool names at a reserved domain, or a kept receipt sender’s domain; a pool name; a capitalised pool word and the lower-case ones after it), never by guessing what a name is. One mapping, keyed by the capture key and built over every file named and every --scan file (the same pages’ older versions), serves the pages and their labels alike, so the labels need no hand edits. A new phrase stays on its old one’s side of a subject hint’s 80 characters, so every hint is cut where it was, and a label’s hint cut from a long phrase gets the new phrase’s cut; every other length is free, since a new fake as long as its old one would confirm a guess by length. The record (--record, required) holds the old fakes and is written outside the repository only. Run it before capture-refake.ts, then the fixture tests:

cd extension && pnpm exec tsx tests/tools/capture-refake-people.ts --record "$TMPDIR/people.json" <fixture.html>… <labels.json>…

The synthetic synthetic-* files were written by hand, not captured, and keep the real sender addresses the receipt parsers match (extension/tests/fixtures/pages/README.md). The @ check applies to captures.

6.4 What the sanitiser cannot catch

It only finds what has a pattern, or what you tell it about. You have to read the output.

It can also over-replace. A name it learned that is also an everyday word (a contact named “Mark” turns “Mark as read” into a fake name) is replaced everywhere. A one-word sender such as Google <no-reply@google.com> is only replaced inside its own element, so vendor names in the interface survive, unless the same word is also a redacted text: a --redact-selector text is replaced wherever else it appears, outside --keep-selector areas. In the captures of 2026-10-06 “Google”, alone as a sender’s name in Gmail’s hidden row summaries (redacted), reads “Glade yonder” on every Gmail page, the account switcher’s “Google Account:” included; no observer reads those words. If an observer needs interface text that was replaced, re-capture without that name in --names, or keep its area with --keep-selector, then check the output again.

6.5 Label

Write the page’s entry in the labels.json beside it: the page facts (view, time zone, read instant, account, live URL), the health each observer must report, every observation it must produce, every coverage window it may report, and for Calendar the events and the expected diff. The format, the field rules and the coverage rules per source are in extension/tests/fixtures/pages/README.md; the schema is extension/tests/unit/fixtures/fixture-labels.ts.

Label from the page, never from the observer’s output: the labels are what the accuracy numbers are measured against (Hard Rule 12).

6.6 Test and measure

cd extension && pnpm exec vitest run tests/unit/fixtures tests/unit/observers
pnpm bench

6.7 Version and record

If the selectors or the reader changed: bump version in the observer’s selectors.json, update the labels’ selectorsVersion, and add one Decisions.md line naming the observer, the new version and the fixture folder it was checked against (§5). Commit the fixture, its labels, the code, the regenerated results and the decision together.

6.8 Tests that cover the capture tool

7. Repairing a broken selector

Vendors change their pages. A selector that stops matching must show up as a DEGRADED report with a reason, never as a silent zero (Hard Rule 6). To repair one:

  1. Read the reason (§4). selector.missing.<landmark> names what the observer could not find. Make sure the cause is the page, not the user’s view or settings.
  2. Capture the broken page into a new date folder with pnpm capture (§6.2). Keep the old fixtures: they are regression cases for the old page, which some users will still see for a while.
  3. Label it with the health and facts the observer should produce (§6.5).
  4. Watch it fail: run the observer’s tests with the new fixture and see the reason you are fixing. A fix that never failed proves nothing.
  5. Fix selectors.json, or the reader if the structure changed. Anchor on role, aria-*, data-* and structure only. When old and new pages must both be read, read both, and keep each fixture’s expected result.
  6. Bump the version and record it (§6.7). Run pnpm bench and commit the regenerated results.

Never, to make a DEGRADED go away: anchor on a class name, skip a row that does not read, guess a time or an amount, widen a coverage window, or loosen health() so it passes a page the extractor cannot fully read. Each of these turns a loud failure into a silent wrong answer (Hard Rules 5, 6).

8. The observers

8.1 gmail.sent

src/observers/gmail/: sent-observer.ts, sent-page.ts, extract.ts, sent-time.ts, account.ts, route.ts. Content script: gmail.content.ts.

What it reads. The Sent list, only when it is the view Gmail shows. Gmail’s own route decides the view (the URL fragment from doc.URL: #sent is page 1, #sent/p2 page 2, #sent/<id> an open conversation); the nav’s selected folder and the document title can only refuse it (DEGRADED view.mismatch). Any other view is IDLE (not_sent_folder, not_sent_list): not a Sent list, so a second Gmail tab never flips the Sent tab’s status. Then exactly one shown role="main" and one shown role="grid" in it. In each row: the subject anchor (data-legacy-last-message-id), the recipients in the cells before it (email, else title), and the time element in a cell after it. It never reads the snippet or the screen-reader summary of the row.

Rows. One email.sent row per recipient: counterparty (the address as read, case kept), subjectHint (one line, 80 code points; none for an empty subject or (no subject)) and occurredAt. The account’s own mailbox is left out of the recipients in any spelling Gmail delivers to it (any case, a +tag, dots in a Gmail address; own-address.ts), as the Calendar reader leaves it out of guests, so it leaves the page only as a hash. A claim naming the account can then match no row, so a row that names the account narrows the window like a conversation row (below): such a claim is UNVERIFIABLE, never NOT FOUND (the first real Sent page held a send to the account; grading contract v1.14). rowKey is data-legacy-last-message-id, so the same row read on two pages is one observation and two identical sends stay two. Only the subject anchor and recipients a row shows are read: real Gmail repeats both in a summary it keeps hidden for screen readers (capture of 2026-10-06).

Time. The time element’s title (absolute), else its aria-label, else its text, each resolved against the pass instant in the browser’s zone; the first that reads as a time decides. Only a minute is a send time. A row is timed only when what the list shows says it holds one message: its last message is its last non-draft one (data-legacy-last-message-id = data-legacy-last-non-draft-message-id), Gmail shows no message count after its recipients (a shown element after the last recipient, in the recipients’ cell, whose whole text is a count: patterns.messageCount), and either its thread id (data-legacy-thread-id) is that id too or the list draws counts at all (some row of it shows one). Gmail draws a count for every conversation of two messages or more, and a mail composed as a draft first keeps the draft’s older id as its thread id, so on a list that draws counts a missing count says one message (live smoke of 2026-10-07: 51 of the 158 rows on the real Sent pages of 2026-10-06; gmail.sent 1.3.0). Any other row whose ids are all there is a conversation of several messages (a reply, a draft on it, a count beside it): it gives no row, because its time is its newest message’s, and the coverage below starts after it. A count beside a row whose ids are one id contradicts them, and that row is not timed either. A list that draws no count anywhere is read by its ids alone. A row missing an id, or with one not in Gmail’s legacy form (16 lowercase hex digits on every captured id; patterns.legacyId in selectors.json), is refused (DEGRADED), so a change of the ids’ markup or form is loud.

Coverage. Only the head of the list (route page 1, no shown enabled Newer control) asserts a window, ending at the pass instant (windowEnd = lastSyncAt). It starts at the oldest row’s minute only when the page provably ends the list (it shows an Older control and every one is disabled). While the list may go on (an enabled Older, or none seen), it starts one minute later: 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, where the pass never looked. The rows of that minute are still observations. The window also starts no earlier than one minute after the newest conversation row, and the newest row that names the account: the user’s sends in them were made by then, and no claim can be matched to them (grading contracts v1.13, v1.14). A list whose shown minutes go up anywhere down the page vouches for no window (DEGRADED window.unknown): the window rests on the list being newest first. A head with no whole minute left asserts no window. Any other Sent page asserts none, by design. The service worker refuses a gmail.sent ping whose window does not end at its sync.

Account. The shared Google-account reader (google-account.ts), used by all three observers: the last (<address>) in a banner label, in any display language (a second signed-in account can carry a note after it: capture of 2026-10-06); two different addresses are selector.ambiguous.account. Required by health().

Known limits. A send in a conversation of several messages is never an observation, and the window starts after the newest such conversation: on the first real Sent page that was 40 rows of 50, so a claim about a reply sent in a thread is UNVERIFIABLE, never NOT FOUND (Hard Rule 5). A list whose rows are all single mails composed as drafts first draws no count to tell them by: they read as conversations, so they give no row, never a wrong one. Gmail’s second page can repeat rows of the first (capture of 2026-10-06: 30 of its 50 rows): a row read on two pages is one observation (rowKey), and a page past the head writes no coverage. While the list may go on, the head gives up its oldest row’s minute: a claim about a send in that minute is not covered by that pass (UNVERIFIABLE on it alone, never NOT FOUND; Hard Rule 5). Newer and Older are found by their English labels: in another display language neither is seen, so the route alone decides the head and its window always starts a minute late. Confirmed on real pages (capture of 2026-10-06): Older carries aria-disabled="true" on the last page and on no other, the time span’s title is the absolute time, and the message count is a shown sibling of the last recipient (repeated in the hidden summary, which is not read). The live smoke (2026-10-07) read the live Sent list OK, so the nav’s selected-folder signal did not refuse it.

Measured by bench/observers/gmail.ts; the numbers are in RESULTS.md under gmail-sent.

8.2 gmail.receipt

src/observers/gmail/receipts/: index.ts, message.ts, classify.ts, parser.ts, amounts.ts, one file per parser. Same content script as gmail.sent.

What it reads. An opened conversation, in the one role="main" Gmail shows (shown.ts, the rule all three observers read with: a view Gmail keeps hidden is never read; two shown are selector.ambiguous.main): the address names a thread (read by route.ts, so a compose window over it still names it), or a shown role="main" holds the subject heading or a message item. A message item is a role="listitem" with a message key (data-legacy-message-id) on it or, as real Gmail draws it, on an element inside it, or with aria-expanded; a list inside a message (attachment chips) has neither. Only expanded messages. Each message’s sender decides its parser:

The body is read only for a message whose sender and subject already make it a receipt candidate, to find the total line and, for a kind that lists refuseText, the words that make it no receipt: Uber sends a charge summary under the trip’s subject that says “This is not a payment receipt” before the trip receipt, and only the receipt is a purchase (gmail.receipt 1.4.0; the live smoke of 2026-10-07 had recorded both). Only the amount leaves the parser.

Total lines. A table row whose first cell with text is the label and whose other cells hold exactly one amount, or a one-cell line <label>[:] <amount>. A row that holds other rows is page layout, unless exactly two of its cells hold text: Stripe wraps each label in a table of its own beside its amount (["", Total, "", $12.34, ""], the shape of the capture of 2026-10-07, amount invented; 1.4.0). An amount whose currency is ambiguous is not read (Rs. is the rupee of five countries: a Shopify order from India is receipt.amount_missing, Hard Rule 5).

Rows. One row per receipt message: purchase.made, subscription.changed or listing.posted, with merchant, amountCents, currency, subjectHint and occurredAt (the message’s date, never a date in the body). rowKey is data-legacy-message-id, so two receipts threaded together stay two.

Coverage. One window per message a platform parser read (a registry sender), the single instant of its date (windowStart = windowEnd). It asserts only that this message was read. Two receipts dated in the same minute give one ping. A generic receipt (any other sender) posts none: anyone can send one, so it is evidence for a claim and never coverage, and a platform receipt dated in the same minute as a generic one of the same pass posts none either, so its point never holds the stranger’s row. A list view, or a conversation with nothing parsed, asserts none. The service worker refuses a gmail.receipt ping wider than one instant.

Account. The shared Google-account reader, as for gmail.sent: any display language; two different addresses where a conversation is open are selector.ambiguous.account. The user’s own mail (any case, a +tag, Gmail’s dots) gets no parser. Without an address the ping carries no account hash, which grading caps, the platform parsers still match only registry domains, and the generic parser reads nothing, because the user’s own message cannot be told apart and Gmail writes the account’s address as its sender name. A list item inside a message (an email’s own markup can carry role and aria-expanded) is part of that message, never a message of its own.

Known limits. Each message is read under its own conversation’s subject heading. Rows carry no parser, so the point of a registry receipt read in another pass can still hold a generic receipt dated in the same minute and make it an orphan (attacks-that-work.md). A merchant whose display name is not plain Latin text is missed by the generic parser, never misread. A bare $ is read as USD, so a non-US shop that writes a bare $ through the generic parser is read in the wrong currency; only US storefront domains are in the registry. Subject kinds and total labels are English. A receipt opened within seconds of arriving, on a device whose clock runs behind Google’s, reports window.future until the next pass.

Measured by bench/observers/receipts.ts; the numbers are in RESULTS.md under receipts.

8.3 gcal.event

src/observers/gcal/: observer.ts, pass-read.ts (one read of the page per pass), read.ts, page.ts, label.ts, health.ts, extract.ts, pass.ts, script.ts, snapshot.ts, days.ts, diff.ts. Content script: gcal.content.ts. Service worker side: calendar-pass.ts, calendar-step.ts, calendar-own.ts, calendar-churn.ts, calendar-window.ts, calendar-seen.ts, calendar-rows.ts. Ledger side: first-look.ts, coverage-merge.ts, clock-guard.ts.

What it reads. The one shown role="main" and the shown grids in it, read together (a week may split into an all-day grid and a timed one): the visible days (data-datekey), the event chips (role="button" with data-eventid, or, in month view, an element with data-eventid around its button) and their accessible labels (start, end, title, calendar, guests), the “N more” controls, the calendar list in the side panel (role="checkbox" with aria-checked, or, as real Calendar draws it, native checkboxes read by their live ticked state; while one labelled list is drawn empty the page never says every calendar is shown), the page’s GMT±hh zone hint, and the account. It never reads an event’s location, conference details or response status into a field.

The snapshot. Each clean pass builds a snapshot of the visible range (snapshot.ts): the days and whether each was seen whole, the calendars (as SHA-256 of their names) and whether each is shown, and per event its key (SHA-256 of data-eventid), its calendar’s key, title, start, end, all-day flag, days, guest addresses (the account left out) and guest count. The service worker stores it in a smaller form: per event the first guest’s address (a deletion’s row names it) and, for every guest, only SHA-256 of the address, ASCII-lowercased, which is all a comparison needs (privacy-architecture.md §5.4). Calendar’s data-eventid itself never leaves the page: it is base64 of the event id and the calendar id, and a primary calendar’s id is the owner’s address.

Who makes the rows. A content script cannot read the ledger, so it never holds the range’s previous snapshot.

Rows. One row per event, never one per guest. subjectHint is the title (none for (No title)), occurredAt the start (local midnight for an all-day event), counterparty one guest address: the first in ASCII-lowercase order with the account’s own mailbox left out (any case, a +tag), or, for an update that added guests, the first one it added; none on a page that names no account, where the account could be any guest. The step from the stored record to a new snapshot gives:

Coverage and changesSince. One ping per run of whole visible days, from local midnight of its first day to local midnight after its last, with allCalendars. The service worker holds a pass’s pings until its snapshot arrives (at most CALENDAR_PASS_HOLD_MS in src/config.ts). Before it stores anything it checks the pings against the snapshot: a ping of another account, or a window outside the range or reaching a day the snapshot hid, refuses the whole pass (invalid_input, DEGRADED ledger.invalid_input). Otherwise it stores, in one readwrite transaction, the rows (each first sighting with its marker), the snapshot record and the windows, or nothing at all (§3.6). It sets changesSince, which grading reads to decide whether a change claim was fully covered and whether a row can accuse an agent (grading.md §3, §6.1):

The pass Its windows are recorded
complete, nothing pending with changesSince = the first readAt of the unbroken run of complete snapshots of this range, account, selectors version and shown calendars (a first look starts a run at its own instant)
complete, an absence waiting for confirmation not at all: no batch may read “every change was seen” while a deletion is unreported (its first sightings still get their markers)
not complete (a hidden day, a calendar named twice) without changesSince: it covers what was there, not what changed

After the clock went back, no window follows changes from before the clock floor (§3.6): a changesSince below it is raised to it, and a pass read before it records its windows without changesSince.

A snapshot read at or before the stored one of its range (another tab’s pass that landed late) is skipped: late_sighting, no coverage, not DEGRADED.

The ledger unites two Calendar windows only when they synced at one instant, or the earlier-synced window lies inside the later-synced one (coverage-merge.ts). Grading counts a Calendar entry towards a calendar claim’s full coverage only if it synced after the claim’s grace, so a union that carried a newer week’s sync over an older week would say a day was seen later than it was: a false NOT_FOUND for an event created there in between.

When a range’s chain restarts. The range’s next pass is a first look (every event a first sighting with a marker, its windows following changes from that pass on, or from the clock floor) in two cases:

A restart loses only what the dropped record was waiting for. A deletion that was pending is not reported, and changes since the last stored pass come back as first sightings: a missed UNASKED, never a false one. A row first seen while the clock was ahead is deleted and derived again unless a grading batch already read it; one a batch read keeps its first sighting, its earlier re-sightings change nothing, and the clock floor keeps it from reading as a change any later window followed.

Known limits. The week grid’s event labels never list guests (live smoke and the invitation week of 2026-10-07: “2pm to 2:30pm, Attest invite test, , Needs RSVP, No location, October 8, 2026"), so no Calendar row names a counterparty: a calendar claim is matched on its title and time, never on a guest. A month view that shows an event is DEGRADED `event.unreadable` for now: real Calendar draws a month's events outside the day cells, naming each day only through a heading's text, so the reader cannot place them on their days yet (a month with no event is read); an event id the page shows that no chip reads always makes the pass DEGRADED rather than leaving the event out while a window vouches for its day (capture of 2026-10-06). The side panel's calendar lists fill in seconds after the grid, and only while the tab is shown; until then the page reads as still drawing (OK, no coverage). Chip labels are read in English only. The browser's zone must match Calendar's. An event moved from one visible range to another is a creation in one and, after two absences, a deletion in the other, never an update: snapshots are stored per range. Rows carry no account, so a real change first seen at the same instant as a first sighting whose event starts at the same instant is blocked by that sighting's marker. That is a missed UNASKED, never a false one. A change compared with a snapshot read more than two minutes earlier is a first sighting and never UNASKED. Since v1.12 every Calendar change is stored as a first sighting with its marker, so no Calendar row is UNASKED until real pages show which facts say the account organises an event and owns its calendar (Sprint 6.4: the live smoke of 2026-10-07 could not read them, the week grid's labels name no guest) ([`grading.md` §3](/attest/docs/grading.html#3-inputs), *Changes the account did not make*). A visible Calendar tab is read at least once a minute (`OBSERVER_HEARTBEAT_MS`, [§3.1](#31-when-a-pass-runs)) and again when it becomes visible; a hidden tab is read only when its page changes, so a change pushed to a tab hidden for longer than two minutes is a first sighting: once Calendar UNASKED returns, a missed one, never a false one. Within two minutes a change is anchored at the pass that reports it. A page with no account stores no snapshot, so its earlier sighting of a new state does not move a change to a first sighting. The clock: a jump forward that came back before anything was stored and before any batch ran leaves instants that are no longer in the future when next looked at, and the wall clock alone cannot tell them from real ones; a grading batch must run the clock guard before it records its instant (Sprint 3.6). A Calendar pass refused for several rows answers only the first refusal's code, because the transaction stops there. A location that contains the text `, Guests:` can feed the guest reader; this is open for the red team (Sprint 6). The live smoke (2026-10-07) found `aria-busy` cleared once a week was drawn: no `page.busy` stayed (a page that kept it is DEGRADED `page.busy` after 5 minutes of passes). A collapsed "My calendars" section was not captured (Sprint 6.4): if collapsing removes its checkboxes from the DOM, a calendar switched off there would be unlisted and `allCalendars` would read true. Calendar has no IDLE yet (sprint_2 2.11 gave one to both Gmail readers): a tab on a view the reader does not read (settings, a search) is DEGRADED `selector.missing.*`, and month view is DEGRADED `event.unreadable`, so beside a week tab the stored `gcal.event` health follows whichever tab posted last. Telling those views apart needs captures of them (Sprint 6.4).

Measured by bench/observers/gcal.ts; the numbers are in RESULTS.md under gcal.

9. Message bus

Content scripts never touch the ledger. They post derived facts to the service worker over the typed bus in extension/src/bus/ (send, sendChecked, postAcked, query, listen), and nothing else may call runtime.sendMessage, onMessage, tabs.sendMessage or connect (lint fence). Every message is one member of the Zod union in src/bus/messages.ts: strict objects, every string and array length-capped (SCHEMA_LIMITS in src/core/schema.ts). send() refuses an invalid message before it leaves; listen() parses on receipt and drops anything else.

Type Sender Receiver Payload Response
adapter.health content script (adapter or observer) service worker health: AdapterHealth (id and version as ComponentId tokens, OK, IDLE or DEGRADED, reason codes such as selector.missing.row (never page text), checkedAt) BusAck: { ok: true } once stored (sprint_2 2.7); invalid_input for an observer the extension does not have or a page on another site; clock_behind for a report checked after the service worker’s clock
adapter.claims content script (agent adapter) service worker adapterId, version, requests: Request[], claims: Claim[] (at most 500 each) acknowledgement
adapter.scope content script (agent adapter, on a settings page) service worker adapterId, version, snapshot: ScopeSnapshot (agent; scopes, as the page names them, one line each, at most 200 of 120 code units; observedAt) acknowledgement; Sprint 3.7 sets the agent’s observedScopes from it
observer.observations content script (Gmail or Calendar observer) service worker observerId, version, observations: Observation[] (at most 500) BusAck: { ok: true } when every row was stored, else { ok: false, code }: for Gmail rows the worst of invalid_input, id_conflict, late_sighting; Calendar rows are stored all or none, each first sighting with its first-look marker, and a refusal answers the first refused row’s code; clock_behind when a row was sighted after the service worker’s clock (nothing stored, not DEGRADED)
coverage.ping content script (observer) service worker ping: CoveragePing (observer id, source, account hash, sync time, window, allCalendars for Calendar, user active; never changesSince). A Sent window must end at its sync, a receipt window is one instant, and a Calendar window must span from a day less an hour to 42 days and an hour, and, with an account, lie on whole days of its pass’s snapshot. BusAck (refused for a pass whose rows were refused, invalid_input for a window the observer cannot have seen or a page on another site, clock_behind for one synced after the service worker’s clock)
calendar.snapshot content script (Calendar observer) service worker observerId, snapshot: CalendarSnapshot (src/observers/gcal/snapshot.ts), accountHash? (the hash of the page’s account, added by the Calendar content script) BusAck (late_sighting for a snapshot read at or before the stored one, not DEGRADED; invalid_input for one that disagrees with its pass’s pings; refused for one whose pass had a refused row; clock_behind for one read after the service worker’s clock, not DEGRADED)
ui.query extension page only (popup, side panel, options) service worker query: { kind: 'ledger.export' } or { kind: 'health.list' } a ui.result of the same kind
ui.result service worker the page that asked (as its response) kind and ok: true with data, or ok: false with error (unknown_query, handler_failed, unavailable) none
ledger.changed service worker open extension pages tables (1 to 14 ledger table names), at acknowledgement; nobody listening is normal
claims.import extension page only (the side panel’s Add agent activity, sprint_3 3.13) service worker agent, claims: Claim[] (1 to 500), each one the paste parser read for that agent (extractor import.<agent>, an activity view, no request); activeAt (at most 10,000 instants): the minutes the log’s timed entries name, stored as the agent’s session pings (sprint_3 3.14); never the pasted text (claims-import.md) { ok: true, received, added } once every claim is stored and a grading batch has run (added: the claims that were new, so a log pasted twice adds 0), or { ok: false, code } (id_conflict, invalid_input, failed)
receipt.event extension page only (the side panel’s receipt detail, sprint_4 4.3) service worker receiptId; event: { kind: 'override', grade, reason } (VERIFIED or NOT_FOUND, and the person’s reason, at most 120 characters with at least one visible, stored on the receipt and never logged) or { kind: 'revert' } (grading.md §8; src/ledger/user-events.ts) { ok: true } once the event is stored, stamped inside the transaction that writes it, or { ok: false, code } (not_found, refused when the state machine refuses it, evidence_taken for a revert whose evidence another claim’s receipt now holds, failed)
grant.save extension page only (the options page’s Grants, sprint_4 4.4) service worker grant: what a person chose for one agent (GrantChoices: allowed actions, recipient domains or addresses, spend ceiling with its currency, declared access), never what Attest observed (observedScopes, accounts) nor a stamp, which the schema refuses; alertsMuted: whether that agent’s alerts are muted. The service worker keeps what Attest observed from the stored grant and stamps updatedAt on its own clock, after the last grading batch { ok: true } once the grant is stored and a grading batch has regraded the agent’s open receipts under it (a FINAL receipt never changes), or { ok: false, code: 'failed' } (a malformed one is dropped unanswered, as every malformed message is)
retention.save extension page only (the options page’s Privacy, sprint_4 4.5) service worker days: 30, 90 or 180 (RETENTION_CHOICES), how long Attest keeps dated history. Stored in ledger meta retentionDays; the daily purge reads it { ok: true } once stored, or { ok: false, code: 'failed' }
reader.toggle extension page only (the options page’s Adapters, sprint_4 4.5) service worker reader: gmail or gcal (READERS); on. The choice is kept in chrome.storage.local (attest.readersOff), and the reader’s content script is registered or unregistered at once (chrome.scripting) { ok: true } once the choice is stored and the registrations follow it, or { ok: false, code: 'failed' }
flag.set extension page only (the options page’s Adapters: Check Gmail for me, sprint_4 4.9; its Model: the on-device model and the person’s own key, sprint_5 5.2, 5.3; its Privacy: the usage numbers, sprint_5 5.7) service worker flag: one of SETTABLE_FLAGS (ATTEST_AUTO_CHECK_GMAIL, ATTEST_MODEL, ATTEST_MODEL_BYO, ATTEST_TELEMETRY; any other flag fails the schema); on. Switching either model stops every model run in progress, its call in flight included (src/background/model-run.ts). The choice is kept in ledger meta (flag.<flag>, src/ledger/consent.ts; src/background/flags.ts), where no script on a vendor page can write it, and the next grading batch reads it { ok: true } once the choice is stored, or { ok: false, code: 'failed' }
byo.save extension page only (the options page’s Model, sprint_5 5.3) service worker vendor: anthropic or openai; key: the person’s own key as its vendor wrote it (letters, digits, - and _, 20 to 300 characters), and that vendor’s: Anthropic’s begin sk-ant-, OpenAI’s begin sk- and never sk-ant- (keyFitsVendor). The service worker stops every model run in progress, closes chrome.storage.local to content scripts, then keeps the key and its vendor there apart (attest.byoKey, attest.byoVendor; src/background/byo.ts), and announces ledger.changed with meta: the vendor is a setting open pages read again, though it lives in that area and not in the ledger. The key is never logged, never in the ledger and never in an export; a refused message is logged by its type alone { ok: true } once both are stored, or { ok: false, code: 'failed' }
byo.forget extension page only (the options page’s Model, sprint_5 5.3) service worker nothing. The service worker stops every model run in progress, deletes the key and its vendor, turns the person’s own key off (flag.ATTEST_MODEL_BYO) and announces ledger.changed with meta { ok: true } once both are deleted, or { ok: false, code: 'failed' }
telemetry.send extension page only (the options page’s Privacy, “Send a test payload now”, sprint_5 5.7) service worker nothing. If telemetry is on and the last whole day was not sent, the service worker sends it now: the hourly alarm’s one call (src/telemetry/send.ts), each day once at most { ok: true, outcome, day }: outcome sent, already_sent, off, not_built or failed (the API did not take it), day the day it was about (YYYY-MM-DD); or { ok: false, code: 'failed' }
ledger.wipe extension page only (the options page’s Danger zone, sprint_4 4.5, asked twice) service worker nothing. The service worker deletes the ledger’s database and clears chrome.storage.local { ok: true } once both are empty (every table then announced changed), or { ok: false, code: 'failed' }
crypto.enable extension page only (the options page’s Encryption, sprint_6 6.5) service worker passphrase: the new passphrase, at least 12 characters, not all spaces, at most 1,024. The service worker derives the key (PBKDF2-SHA256, 600,000 rounds, a new random salt), stores the key’s id, salt, rounds and check in meta, seals every row written from then on, starts the job that seals the rest, and keeps the key in its memory and, for the extension’s pages, in chrome.storage.session (src/background/crypto.ts). Never logged CryptoResult: { ok: true }, or { ok: false, code }: already_on, busy (a job is still rewriting the rows) or failed
crypto.unlock extension page only (the popup’s or any page’s unlock prompt, sprint_6 6.5) service worker passphrase (1 to 1,024 characters). The key it gives must open the ledger’s key check before any row is read; a passphrase change a stop interrupted gets the key before from meta, sealed under the new one, and the job resumes { ok: true }, or { ok: false, code }: wrong_passphrase (the ledger stays locked and nothing is read), damaged (the record of the key in meta cannot be read, or the key before does not open with the right key: logged at error by its field, and the ledger stays locked, whole), off or failed
crypto.lock extension page only (the options page’s Encryption, “Lock now”) service worker nothing. The service worker forgets the keys and clears the pages’ copy; a job under way pauses until the next unlock { ok: true }, or { ok: false, code: 'failed' }
crypto.change extension page only (the options page’s Encryption) service worker passphrase, the current one, and next, the new one (as for crypto.enable). A new key with a new salt; the current key is kept sealed under it until the job has sealed every row again { ok: true }, or { ok: false, code }: wrong_passphrase, damaged, off, busy or failed
crypto.disable extension page only (the options page’s Encryption) service worker passphrase. Rows written from then on are plain, and the job opens every sealed row for good; when it ends, every crypto. key goes from meta and the keys from memory { ok: true }, or { ok: false, code }: wrong_passphrase, damaged, off, busy or failed
crypto.resume extension page only (the options page’s Encryption, Try again after a job stopped; Sprint 6 close, tech review TECH-2) service worker nothing. The failures the job counted go, and it resumes where it stopped; the grace alarm also retries a stopped job, up to MAX_JOB_TRIES failures in a row { ok: true }, also when no job is recorded, or { ok: false, code: 'failed' } (the ledger locked, or the command failed)

Since Sprint 2.7 the service worker also handles the four observer messages (src/background/observer-messages.ts, src/background/calendar-pass.ts) and answers each with a BusAck. A content script posts through postAcked (src/bus/send.ts), which rejects on { ok: false } or a transport failure, so the observer stops the pass and never posts coverage after a refused row. A DEGRADED the service worker sets (reasons ledger.<code>) stays until a later clean pass of the same observer is fully accepted. Adapters (3.6) add their handlers there.

Drops (bus.dropped, logged with the reason and, when it is one of ours, the type; never the payload): foreign_sender (not this extension, or an orphaned context), malformed (fails the schema, unknown types included), untrusted_context (ui.query, claims.import, receipt.event, grant.save, retention.save, reader.toggle, flag.set, ledger.wipe, byo.save, byo.forget, telemetry.send, crypto.enable, crypto.unlock, crypto.lock, crypto.change, crypto.disable or crypto.resume from a content script), each a warning; no_handler (this context does not handle that type, routine wherever several pages listen), at debug.

Send failures (bus.send_failed, the message resolves undefined): no_receiver, port_closed, context_invalidated, send_failed, each a warning, except no_receiver for a broadcast (ledger.changed), which is expected when no page is open and logged at debug. An invalid outgoing message is never sent: send() rejects with BusError('invalid_outgoing'). sendChecked() (acknowledged messages) never resolves undefined: it rejects with the transport code, or invalid_reply when the answer is not a BusAck; postAcked() also rejects with refused on { ok: false } (logged at debug as bus.refused).

10. Agent adapters

Sprint 3.0 adds the skeleton every Muse, ChatGPT/Dots and Claude adapter runs (src/adapters/base.ts) and the tables that read an agent’s sentences (src/adapters/patterns.ts). Every agent reader is off (AGENT_READERS in src/config.ts; Decisions [3.10]): Meta’s, OpenAI’s and Anthropic’s terms forbid collecting data from their sites by automated means, so no agent host is in the manifest and no agent content script ships, and the options page says why. The Muse adapter (src/adapters/muse/) is kept, tested on the hand-built pages of tests/fixtures/pages/muse/, for the day Meta permits it; its host is known (https://muse.ai/*). Agent pages are captured by hand (Chrome’s Save Page), never by driving the browser. An agent page gives Requests (what the user asked), Claims (what the agent says it did) and, on a settings page, a scope snapshot (what the agent may reach). It gives no Observations and no coverage: those come only from the observers.

10.1 The contract

Member Rule
id, vendor, version ComponentId tokens, as an observer’s (§2).
agent muse, chatgpt, dots or claude.
hosts Exact match patterns of that agent in AGENT_HOSTS (src/config-agents.ts); Dots runs on ChatGPT’s. A host enters the manifest only with the content script that reads it, and only while its reader is on (AGENT_READERS): none is (Hard Rule 8; Decisions [3.10]).
health(doc, ctx) As an observer’s: OK, IDLE or DEGRADED with reason codes, never page text.
extract(doc, ctx) One AgentRead (types.ts): the conversation’s vendor id or null, its turns (role, text, time as the page shows it, optional rowKey), the activity entries (text, time, optional rowKey), and a settings page’s scopes (null on any other page). Pure over the DOM: if a claim is not rendered, it is not read.
attributes (optional) As an observer’s.

10.2 One pass

The skeleton runs on the observer skeleton’s loop (startPasses in src/observers/base.ts): the same batching, prerender rule and stop when the extension is gone (§3.1). Each pass reads the clock once and posts, one message at a time and each after the last was answered: adapter.health; then adapter.claims, at most 500 Requests and 500 Claims per message, none when the page holds neither; then, on a settings page whose pass was clean, adapter.scope, each scope one line and once. A post that rejects ends the pass.

What becomes what (claims.ts):

10.3 How a sentence becomes a claim

classify(agent, sentence) holds one sentence to the agent’s table (pattern-tables.ts). Rules first, never a guessed type:

The sentence Becomes
asks, offers, plans or drafts, and names an action (“Should I send…”, “I’ll create…”, an approval card) other, confidence 0.2
refuses, or reports a step still under way (“I couldn’t…”, “Sending email…”) no claim
matches patterns of one ActionType that type, confidence 0.7 to 0.9, with what the pattern captured
matches patterns of two types other, confidence 0.4
says it acted without saying how, or names an action outside the ActionTypes (an RSVP, a share) other, confidence 0.3
anything else, or longer than 400 code units no claim

A sentence wrongly read as an action can end in a NOT_FOUND against the agent, so the tables err toward losing a claim, never toward inventing one: their tests check that no labelled sentence is ever typed as an action it is not. The corpora are invented until real transcripts exist (tests/fixtures/patterns/); measured accuracy on captured pages arrives with 3.8.

10.4 Reasons the adapter skeleton gives

Beside the reasons an adapter’s health() names, a pass can be DEGRADED with these (ADAPTER_REASONS):

Reason Meaning
health.threw, health.invalid As an observer’s (§4.1).
extract.threw extract() threw.
extract.invalid extract() returned something that is not an AgentRead.
claims.threw Building the Requests and Claims threw: a bug.
time.unknown A turn or entry that held a Request or a Claim shows no time the skeleton reads, or one after the pass. What was read is still posted; no scope snapshot is.
claim.invalid A Request or a Claim failed its schema: a bug, never expected. What else was read is still posted.
message.invalid A message of the pass failed its bus schema: only this health report is posted.

10.5 muse.chat reasons

Reason Status Meaning
view.unread IDLE The page shows no conversation, activity view or connected apps (a business dashboard, a help page): nothing to read, nothing wrong.
selector.missing.conversation_id DEGRADED A conversation shows no id: its Requests cannot be named.
selector.missing.message_text DEGRADED A message has no text element: Muse changed how it draws one.
selector.missing.entry_summary DEGRADED An activity entry has no summary.
selector.missing.app DEGRADED A connected app has no name or no switch.

Every Muse selector is synthetic until the captures; a capture that changes one bumps selectors.json’s version (§5).