Feature ledgerPaper · rendered 2026-09-12 21:15
198 rows

Feature ledger

This document is local, and it cites 15 paths that only exist beside it — every dev-docs/… reference below, the dev-docs/artifacts/*.png captures offered as evidence, and docs/design/circle/ with the wire and relationships notes. That is fine while nobody outside this machine reads it, and it is the first thing to deal with if that changes: publishing this file was set up on 2026-09-09 and abandoned the same day, uncommitted, and those fifteen dead links were one of the two reasons it is not a one-line change.

⚠️ pnpm features:check NO LONGER EXISTS, AND pnpm ledger:check REPLACED IT ON 2026-09-10. The old gate read both ledgers and checked that every path in the Where column named a file that exists. Both ledgers moved into dev-docs/, which is gitignored whole, so it had no input on a clean checkout and was removed rather than made conditional on an untracked path. For five weeks nothing replaced it, and the 2026-09-10 audit is what that cost: eight findings, of which one was a whole surface of fifteen shipped settings with no row.

pnpm ledger:check asks three questions — every Where claim resolves, every State cell is one of the five, and every registry name the app declares is written in some inventory row. The third is new and is the one that catches a surface nobody described. ⚠️ IT STILL CANNOT BIND A CLONE. On a checkout without dev-docs/ it skips, and says so in one loud line naming the gitignore rule; pnpm ledger:check --require turns that skip into a failure. It guards the machine where the ledger is written, which is where every one of the eight findings was introduced, and that is all it guards.

How to write a Where cell

core/… and ui/… are kernel-relative and resolve under src/kernel/. Everything else is written from the repository root, including every capability path: src/capabilities/circle/lib/covers.ts, not lib/covers.ts.

⚠️ THE SHORT FORM WAS TRIED FOR THE CIRCLE AND PUBLIC SECTIONS AND IT BROKE THE COLUMN. Fifty-nine of 312 claims stopped resolving, because a bare lib/… and a capability-local ui/… collide with the kernel's own prefix — so ui/CirclePane.tsx pointed at src/kernel/ui/CirclePane.tsx, which does not exist. Three were ambiguous to a person, not only to a checker: lib/covers.ts, lib/crypto.ts and lib/overlay.ts each named two real files in different capabilities. A section-scoped root was considered and rejected — it makes a path mean different things depending on the heading above it, so moving a row silently changes its claim. The verbosity is the point: this column is read by somebody deciding where to look.

Two jobs, one document.

  1. Inspect one by one. Part 1 is every capability Paper claims, what state it is actually in, where it lives, and how to confirm it in a running app. It is written to be walked top to bottom with the app open.
  2. Find the gaps. Parts 2 and 3 put Paper beside ten other readers and say plainly what is missing, what is deliberately absent, and what nobody else has.

State means one of five things, and the distinction is the point of the document:

State Meaning
Shipped Implemented, and verified working in the app
Partial Works, with a named limit
Stub Types, seam or UI exist; the behaviour does not
Absent Not built
Unknown Inherited from a dependency and never tested here — claim nothing

A stub is not a feature. Where this ledger says stub, the app should not be described as having the thing.

Re-audited 2026-08-21 against the working tree, row by row. Eleven rows had drifted — nine understated a shipped feature, and every path in the file predated the kernel carve. What changed, and why the drift happened at all, is in "The 2026-08-21 re-audit" at the end.

Re-audited again 2026-08-23, after the service-table merge, for the two phases that landed in between. Seven rows added, two corrections where this document contradicted itself, and three rows moved out of a table whose heading had stopped describing them. See "The 2026-08-23 re-audit".

And once more after phase 15, the companion runtime. That phase updated its own rows as it landed — which is the practice this document wants — so this pass was for what a phase cannot see about itself: a name it collided with, a row elsewhere its work made stale, and a matrix cell nobody owns. See "After phase 15".

Re-audited 2026-09-05, at 0.2.0. Five days, three phases and three releases, and this document had three whole surfaces with no row at all: the circle, the phone and the browser client. It is the largest gap any pass has found — not a row that drifted, three sections that were never written. Thirty-one rows added. Seven existing rows had gone stale with them, one of them announcing a user-visible regression that had since been reversed, and four paragraphs of Part 4 were arguing from rows that had changed under them. See "The 2026-09-05 re-audit".

And a narrow pass on 2026-09-06, at 0.2.1. Not an inventory sweep — one row, and it is the Sync row. Five commits landed after the 09-05 pass and four of them are test and harness work that changes no capability; the fifth (2022ef7) removed the reason WI-8.6 was recorded Partial. What this pass actually did was read the sync source against the Stage B transcript the row had just been given, and two of that transcript's three headline observations turned out to measure nothing. See "The 2026-09-06 pass".

And a pass on 2026-09-09, at 0.3.0. Forty-three commits, one merge — the audit of every deferred finding, and then three evenings on a pairing that did not work. Two rows changed and both were wrong in the same direction: they claimed more than had been observed. Admission: two shelves pair said Shipped over a capability that failed three times in four for any real reader, with twenty green protocol tests over it; The circle, end to end held both "Both DIRECTIONS are proved" and "the far end has never published" in one cell, three sentences apart. See "The 2026-09-09 pass".

And a pass on 2026-09-10, at 0.3.1. The largest gap since the 09-05 sweep, and the same shape: two whole surfaces with no row at all. Phases 25 and 26 had landed the day before — twenty work items, two ALPNs, a second signing key, a global index — and this document had not one row for any of it. Twenty-one rows added, under a new heading. Two existing rows had gone stale under them, one of which had begun contradicting a row that did not exist yet. See "The 2026-09-10 pass".


Part 1 — What Paper has

Reading surface

Capability State Where How to confirm
EPUB rendering Shipped ui/reader/FoliateView.tsx, foliate-js Open ?book=/sample.epub; text renders
PDF rendering Shipped ui/reader/makePdf.ts Open a PDF; a canvas paints with a text layer over it
PDF as a first-class book Shipped ui/reader/makePdf.ts Marks, search, ruler and read-aloud all work on a PDF, through the same code as EPUB
MOBI / AZW3 / CBZ / FB2 / FBZ Shipped core/formats.ts (ACCEPT_FORMATS, which is exactly .epub .pdf .mobi .azw3 .cbz .fb2 .fbz), src/kernel/ui/reader/formats.corpus.test.ts, scripts/make-format-fixtures.mjs, tests/fixtures/ Was Partial — "not exercised by any test or manual pass" — and the row named FOUR formats where ACCEPT_FORMATS offers FIVE: .fbz had been on the accept list the whole time, in no row, no test and no plan. All five now open, paint, populate a TOC and take a bookmark that survives a reopen — driven in the running app on 2026-09-05, one capture each in dev-docs/artifacts/format-*.png. Twenty cases cover the parse, including a known negative (a truncated MOBI is refused; an empty file is refused by name), because a loader that returned an empty book would satisfy every "contains" assertion by containing nothing. The finding worth keeping: foliate hands author back in THREE shapes — a bare string for EPUB, [{name, sortAs}] for FB2/FBZ, ["…"] for MOBI/AZW3, and nothing at all for CBZ. readMeta's text() folds all three and its docstring predicted them; there is now a case per format that proves it. CBZ is the odd one and is asserted differently: no title, no author, no text — one section per image, and a title that is the file name, extension included, which titleAsParsed strips (it was written for the Batman.cbz.cbz.cbz defect and this is the first time anything confirmed it in the app). Named limits, all measured rather than assumed: ebook-convert adds a cover and a generated TOC, so a two-section EPUB becomes four sections as MOBI and three as AZW3 — the test asserts more than one rather than pinning Calibre's layout; and FB2 needs URL.createObjectURL, which jsdom lacks and every real webview has
Scrolled and paginated flow Shipped Settings → Page → Flow (pageLayout) One toggle, one default, both renderers. A reflowable book takes it as flow; a fixed-layout one — every PDF — as zoom, where fit-width overflows the page and scrolls it and fit-page shows the whole of it. Scrolling is per SECTION, not continuous — see the row below
Continuous scrolling Absent — Scrolled flow scrolls WITHIN one section and crosses to the next at the edge, so there is a seam at every boundary — every page of a PDF, every chapter of an EPUB. foliate holds one section's document at a time, which is architecture rather than a setting. A PDF used to scroll continuously: PdfView.tsx laid every page out in one scroller and painted them lazily, and commit 21ced8a deleted it to make a PDF a first-class foliate book. Reviewed 2026-08-16 and the current shape kept deliberately
Page turn slides Partial ui/reader/FoliateView.tsx, ui/reader/pageTurn.test.ts foliate eases #scrollTo over 300ms when animated is present. The named limit: only turns WITHIN a reflowable section slide. A PDF is drawn by foliate-fxl, which never reads the attribute, and a turn crossing into another spine item is a load rather than a scroll. No setting exposes it, deliberately; a system request for less movement switches it off
Page turn survives being asked mid-slide Shipped ui/reader/session.ts (commit 0dde843) Turn a page while one is already sliding — the second turn is queued, not discarded
Scrollbar, placed and optional Shipped ui/screens/Reader.module.css, fork paginator.js, scrollbarOn Scrolled flow: the bar rides the reading area's edge rather than the text column's, and Settings → Page → Scrollbar turns it off — which is the default
Progress rule, in the book's own colour Shipped core/bookAccent.ts, progressLineOn Settings → Page → Progress rule (hidden by default). A 3px rule down the leading edge, filled to the book fraction. The hue is derived from bookId, so it is unpredictable across books and identical for one book forever — a signature, not a random draw
14 reading sizes, 15–28px Shipped core/metrics.ts READING_STEPS, textSize, ui/pane/Settings.tsx Settings → Text, or ⌘+ / ⌘− / ⌘0; the measure changes with the size, per §09
Size corrected for the face's x-height Shipped core/typefaces.ts (REFERENCE_X_HEIGHT, SCALE_BOUNDS), ui/fontProbe.ts Two faces at 21px do not read the same size, because what the eye reads is the x-height. The reference is Literata's, measured at 51 per 100; every other face's step is scaled against it. Switch between two offered faces at one size and the text stays the size you asked for — pick a serif and a monospace, which are the furthest apart of what a machine offers. ⚠️ THE WORKED EXAMPLE IN THIS ROW USED TO BE CRIMSON PRO, AT 42 PER 100 — A MEASUREMENT THAT IS STILL TRUE AND A FACE THAT CANNOT BE SELECTED, because it is bundled for the app's chrome and is not a reading face; see Typeface choice. The correction is bounded to ×0.86–×1.18 so a face that fails to load, or a canvas that returns zero, produces something slightly off rather than something absurd
4 spacing axes Shipped core/metrics.ts SPACING, Settings → Text Letter (tracking, in em, one negative step offered), word, line, paragraph. Steps, not sliders — a value between two steps is not a decision a reader can hold
Brightness and contrast Shipped core/metrics.ts BRIGHTNESS / CONTRAST, ui/reader/bookCss.ts Settings → Appearance. Resolved to numbers before injection, because the book is an iframe and the app's custom properties do not reach it. Moves the text only
Alignment and hyphenation, as one decision Shipped core/uiTypes.ts ALIGNS, align, ui/reader/bookCss.ts, Settings → Text → Alignment Three states: justified (both edges flush, long words broken), justified-no-hyphens (flush, word spaces stretched instead), ragged. Deliberately three rather than four — the reader cycles decisions rather than assembling one from two booleans. Measured: worst word gap 12px hyphenated against 27px without, on a 4px natural space, which is what rivers are
Hyphenation that knows the language Shipped ui/reader/session.ts ensureLang hyphens: auto with no lang fails silently — applied, reports nothing, does nothing. A section without xml:lang inherits dc:language from the OPF; a section that declares its own is the authority, so a quotation is not hyphenated by the wrong language's rules
Alignment respects composition Shipped ui/reader/markProse.ts The reader's alignment reaches prose paragraphs and leaves centred text alone — a centred dedication is the book composing, not a default to override
5 themes + follow OS Shipped ui/panes.ts THEMES, themeFollowsOs, Settings → Appearance Paper, Slate, Sepia, Sage, Night. The specimen shows type, not a colour chip
Injected book typography Shipped ui/reader/bookCss.ts Book <p> computes to the chosen face at the step's size and line box
The book sheet is two static tiers Shipped ui/reader/bookCss.ts bookSheets, noteSheets Two sheets that never change, injected once. What it replaced rebuilt all 585 lines as a string on every change to step, theme, typeface, spacing, alignment, brightness or contrast — re-reading document.styleSheets on the way — and re-injected it, forcing a full CSS re-parse in every open document. The failure mode the tests exist for is invisible: a rule in the wrong tier still parses, and a variable the sheet reads but the contract never writes drops at computed-value time and renders the book unstyled with nothing logged
A setting is a property write Shipped ui/reader/bookCss.ts applyBookVars, bookVars Changing a setting writes custom properties on each document's root; the sheets do not move. That is what makes a size or theme change cost no re-parse, and it is what gave the fidelity dial somewhere to live
The base size is on the root Shipped ui/reader/bookCss.ts, ui/reader/bookSize.test.ts html { font-size: <step>px }, and body is tied to it with 1rem rather than repeating the number. On body instead — which is where it was — every rem in every book stays pinned to the browser's 16px however the reader moves the control, and roughly a third of the library sizes itself in rem
House ratios for what is not running text Shipped core/metrics.ts READING_RATIOS, ui/reader/bookCss.ts A quotation, table, list and code block at 90%, a footnote at 80%, inline code at 90% of the line it sits in. Paper set none of these, so all of them inherited 100% and read, next to a paragraph, like more paragraph
The fidelity dial — whose typography wins Shipped core/uiTypes.ts (FIDELITIES), core/settings.ts (ReadingStyle.fidelity), Settings → Text → Typography Two states, paper and publisher, and the default is paper. Under publisher the book's own statement wins wherever it makes one and Paper's house defaults remain for the books that state nothing. ⚠️ IT DOES NOT TOUCH THE READER'S OWN CONTROLS — size, measure, leading, alignment and theme are the reader's under both, "because a control that can be overruled is not a control." It governs only the house DEFAULTS, which is the part that was never anybody's decision. ⚠️ AND THIS IS THE FIRST ROW ANY OF THE FIFTEEN ReadingStyle FIELDS HAS EVER HAD. Until 2026-09-10 the whole of the reader's typography surface below was undescribed; fidelity was named exactly once in this document, inside the justification of A setting is a property write, which is where it lives rather than what it does
Separation between paragraphs Shipped core/uiTypes.ts (SEPARATIONS), ReadingStyle.separation, Settings → Paragraphs → Separation space, indent or both — the two conventions a book can use to say a paragraph ended, offered as one three-state decision rather than two booleans, the way alignment and hyphenation are. Default space
Opening flourish Shipped core/uiTypes.ts (FLOURISHES), ReadingStyle.flourish, Settings → Paragraphs → Opening none, drop-cap or small-caps on a section's first paragraph. Default none, so a book that composes its own opening is not overruled by one nobody asked for
Heading sizes Shipped core/uiTypes.ts (HEADING_SCALES), ReadingStyle.headingScale, Settings → Paragraphs → Heading sizes publisher or paper. Default publisher — the same axis the fidelity dial runs on, offered separately because headings are the place a book's own scale is most often deliberate
Space between CJK and Latin Shipped core/settings.ts (ReadingStyle.cjkSpacing), Settings → Spacing → Space CJK and Latin Off by default. The thin space that belongs between a Han character and a Latin word, which no EPUB supplies and no engine inserts. ⚠️ THIS CELL SAID Settings → Paragraphs UNTIL 2026-09-11 AND THE CONTROL HAS NEVER BEEN THERE. It is the last row of the Spacing group, below Paragraph — Settings.tsx's own comment says why, "a space between CJK and Latin, which is a spacing and belongs here". The row was written beside the other fourteen ReadingStyle fields on 2026-09-10 and took the neighbouring band's name; every other Settings → … path in this document resolves, checked one by one against the band map. A wrong path in this column is worse than a missing one, because the column's whole job is to tell somebody where to look
Quotation treatment Shipped core/uiTypes.ts (QUOTE_STYLES), ReadingStyle.blockquote, Settings → Blocks → Quotations indent, rule or tint. Default indent. Three ways to say "this is quoted", where a book usually says it once and badly
Code face Shipped core/uiTypes.ts (CODE_FACES), ReadingStyle.codeFace, Settings → Blocks → Code face publisher or paper. Default publisher
Long code lines Shipped core/uiTypes.ts (CODE_WRAPS), ReadingStyle.codeWrap, Settings → Blocks → Long code lines scroll or wrap. Default scroll — a wrapped line of code is a line that lies about where it ends, and the reader who wants it whole asks
Wide tables Shipped core/uiTypes.ts (TABLE_FITS), ReadingStyle.wideTables, Settings → Blocks → Wide tables scroll or shrink. Default scroll, for the same reason as code: shrinking a table to fit is a silent change to its type size
Note size Shipped core/uiTypes.ts (NOTE_SIZES), ReadingStyle.noteSize, Settings → Blocks → Note size prose or publisher. Default prose. The counterpart to READING_RATIOS' footnote at 80%: the ratio is the house default and this is the reader's say over it
Figure width Shipped core/metrics.ts (FIGURE_WIDTHS), ReadingStyle.figureWidth, Settings → Figures → Width Five steps, 70–100% of the measure, defaulting to the scale's own def. Steps rather than a slider, on READING_STEPS' rule — a value between two of these is not a decision anybody made
Figure height Shipped core/metrics.ts (FIGURE_HEIGHTS), ReadingStyle.figureHeight, Settings → Figures → Height Four steps, 50–95% of the page, so a full-page plate does not push the text it belongs to off the screen
Figure frame Shipped core/uiTypes.ts (FIGURE_FRAMES), ReadingStyle.figureFrame, Settings → Figures → Frame none, hairline or shadow. Default none
Figures scale with text Shipped core/settings.ts (ReadingStyle.figureScalesWithText), Settings → Figures → Scale with text Off by default. On, a figure's width follows the reading size rather than the viewport, so a diagram keeps its relation to the words at every step
Minimum size Shipped core/metrics.ts (MINIMUM_SIZES), ReadingStyle.minimumSize, Settings → Text → Minimum size Four steps: 0 (no floor), 11, 12, 14px. A floor under whatever the BOOK sets, for the books that set 0.8em inside 0.8em inside 0.8em. 0 is the default, because a floor nobody asked for is a book silently re-set
A panel behind code Shipped ui/reader/bookCss.ts Mixed from the reader's own ink rather than named as a grey — a literal light grey is right on four themes and a bright slab on the fifth. Padded by the PAINTED height rather than the font size, because a background on an inline element paints the content area. On the dark page the blanket clear has to stop MATCHING rather than be outranked, since the panel at (0,0,1) cannot beat a clear at (0,4,1)
Typeface choice Shipped core/typefaces.ts (BUNDLED_FACES, offeredFaces), ui/fontProbe.ts (presentFaces), ui/reader/bookCss.ts Settings → Text, each row set in the face it offers. THREE faces ship and the rest are found: Literata, Instrument Sans and IBM Plex Mono are bundled and offered anywhere, then presentFaces probes this machine and offeredFaces adds at most NAMED_PER_GROUP (2) of the twelve named system faces per group — so what a reader sees is not a fixed number, and a Mac with seven of the serifs is deliberately not shown seven. ⚠️ THIS ROW SAID "four faces … Literata, Crimson Pro, Instrument Sans, IBM Plex Mono — exactly what main.tsx bundles, checked against it by a test" UNTIL 2026-09-10, AND EVERY CLAUSE OF THAT WAS WRONG. main.tsx loads FOUR families and BUNDLED holds THREE; Crimson Pro is loaded for the app's own chrome — four font-family rules across SidePane, Library and Reader module CSS — and is not in ALL_FACES at all, so it cannot be selected and faceById('crimson') falls back to Literata. main.tsx's own comment says the counts differ by one on purpose. And the cited test asserts the opposite relationship: ui/state.test.ts checks that every offered bundled face is one main.tsx loads, not that the two lists are equal, and explains in a comment why Crimson Pro is bundled and not offered. The @font-face-to-Georgia hazard the old row named is real and is what that test defends
Fixed-layout annotation Shipped fork commit FXL: give fixed-layout an overlay layer Highlights draw on a PDF page — upstream has no overlay layer there
Cross-document coordinates Shipped ui/reader/coordinates.ts foliate renders each spine item in an iframe and pdf.js paints a canvas; the ruler, margin marks, selection popup and baseline grid all need one coordinate space. This is it
Reading position restore Shipped core/positionRecorder.ts, core/libraryStore.ts Read into a book, close it, reopen it from the shelf — it lands where you left off, across a relaunch. Was Partial on a dependency that is no longer load-bearing; see the re-audit note
Bookmarks Shipped core/marks.ts (kind: 'bookmark'), ui/hooks/useBookmarking.ts, ui/pane/Marginalia.tsx ⌘B, or the bookmark button at the foot of the page: a ribbon appears at the page's trailing corner and the place is listed under ⌘2. A bookmark is a RECORD IN marks.json, not a file of its own — so it inherits the tombstones, the HLC stamps, the per-folder write queue and the whole sync path, and it replicated between devices the day it landed with no wire change. The trap, and it is not theoretical: a bookmark anchors to the VISIBLE PAGE, so it overlaps every highlight on that page — and upsertOverlapping replaces what a new mark overlaps. Without sameClass guarding it, bookmarking a page tombstoned the highlight on it
Footnote popover Shipped ui/reader/FootnotePopover.tsx, ui/reader/session.ts, foliate-js/footnotes.js EPUB only. (The state cell read "Shipped (EPUB)", which is not one of the five states, and gen-feature-ledger.py refused the whole document for it the first time it could read it — 2026-08-28.) Click a note marker and the note opens where you are looking; Esc, a click outside or the next section closes it. Detection is upstream's — epub:type, ARIA roles, and a superscript heuristic for the majority of books that declare nothing — so it works far past the one type bookCss styles. A note that cannot be shown in place NAVIGATES instead, pushing the jump stack, so ⌘[ comes back. The box is a fixed height: sizing it to the note re-columnizes the paginator and the text moves off-view — see the plan
Back after a jump Shipped core/jumpStack.ts, ui/hooks/useJumps.ts, ui/accel.ts ⌘[ and ⌘], plus two palette rows and a fading "← Back to <chapter>" after each jump. A TOC entry, a search hit, a Marginalia or Cards row and a link inside the book all record where the reader was; a page turn does not, and nothing in the paging path can — it never holds the hook. The search hit was a claim, not a fact, until 2026-08-28: SearchPanel called book.goTo directly and nothing entered the stack; it goes through the host's jumpTo now (WI-20.8) Cross-book too: the stack carries { bookId, cfi }, so ⌘[ reopens the book you came from at the place you left
Image / figure zoom Absent — No tap-to-enlarge, no lightbox, no pinch on a plate. Was modelled in state and removed as dead. ⚠️ THIS IS ABOUT ZOOM AND NOT ABOUT FIGURES, and until 2026-09-10 it was the only row in this document that mentioned a figure at all — so it read as "Paper does nothing with images" while four figure controls shipped. Width, Height, Frame and Scale with text have rows of their own above
Auto-scroll Absent — No command, no setting, no keybinding
Parallel / split reading Absent — One FoliateView per window; nothing renders two
Per-book settings Absent — Settings are global. A position, not a gap — see Part 4
RTL, vertical writing Unknown inherited from foliate-js Never tested; do not claim it. Page turning is spatial (goLeft/goRight) so the direction is at least modelled — and since 2026-08-28 the arrow keys ask for a side too (resolvePageKey, WI-20.12); before that → was next and moved against the chevron beside it in a right-to-left book

Annotation

Capability State Where How to confirm
Highlights, anchored by CFI Shipped core/marks.ts, ui/reader/session.ts Mark a passage; it survives a reload
Three tints Shipped core/marks.ts MARK_TINTS, markTint Yellow, green, purple. The chevron on the selection bar opens the picker; the bar's own button carries the tint it will lay down, so it says what pressing it does
Two styles the reader may choose Shipped core/marks.ts READER_STYLES, markStyle Fill and underline. MARK_STYLES also carries wave, reserved for the companion and deliberately not offered — a machine-written mark must not be confusable with one the reader made
Tints derived, not picked Shipped scripts/mark-tints.mjs, ui/reader/markTints.test.ts All thirty values come from one hue per tint, one chroma per role, and one lightness step per role, differing between light pages and the dark one. The test is what stops the table drifting from the model
Notes on a highlight Shipped ui/pane/Marginalia.tsx Write a note; it saves on blur, unmount and window hide
A note in flight is not lost Shipped core/beforeClose.ts Close the window mid-sentence. The close is intercepted, the unsaved note is handed to the write queue, and the queue drains before the process ends
Margin notes beside their line Shipped ui/reader/MarginMarks.tsx A note sits level with its first line, clipped to the visible page
Selection tools Shipped ui/reader/SelectionTools.tsx Three faces: the bar (mark, styles, note, copy, copy options, look up, remove), the mark-style picker, and the copy menu. Clamped inside the stage by core/placement.ts
Copy with citation Shipped ui/reader/SelectionTools.tsx, core/citation.ts The passage with its book, author and place — "p. 12" for a PDF, a chapter for an EPUB
Word-boundary snapping Shipped ui/reader/wordSnap/ — 11 modules and 2 corpora A drag-selection expands to whole words. The policy is expand the touched word, trim edge separators, not "move each edge to the nearest boundary" — in abc, a start edge at offset 2 is nearer 3 and the right answer is 0. Has its own corpus test. ⚠️ THE COUNT SAID 12 AND NO READING OF THE DIRECTORY EVER GAVE 12. Ten modules landed on 2026-08-19 and the gloss added three on 08-24, so it has been 13 non-test files — 11 modules plus corpus.ts and sentenceCorpus.ts, which no production module imports — since before this row was last touched. A count in a Where cell is a fact with a half-life, exactly as the Cargo.lock count in AGENTS.md is; it is spelled out here rather than left as a number because the directory is the authority
Snapping knows where a selection came from Shipped ui/reader/wordSnap/gestureProvenance.ts Snapping is for drags. A ⇧-arrow selection keeps character granularity — extending it to words would take the keyboard's one useful property away. pointerup cannot tell them apart, so provenance is tracked
Overlap-based mark matching Shipped core/markMatch.ts A mark and a selection are the same passage when their CFIs OVERLAP, not when they are byte-identical — selecting part of a marked paragraph used to find nothing and offer to stack a second mark on it
Companion provenance mark Stub core/marks.ts (kind: 'companion') The kind, its amber treatment and the reserved wave style exist; nothing produces one
Strikethrough / ink / freehand Absent — READER_STYLES is fill and underline; foliate's other painters are unused
Annotation export Shipped core/marksArchive.ts, ui/marksFiles.ts, ⌘K → "Export your marks and cards…" / "Import marks from a file… (merge)" Versioned document, JSON to re-import and Markdown to read — both rendered from the same document, so the two cannot drift. Carries the quote, its 32 characters of context, the note, the tint and style, and the chapter; the CFI is written as localAnchor because it is exact here and meaningless anywhere else. The ledger's HLC stamps and tombstones are excluded on the tag archive's rule: they regenerate, and importing them would forge a causal history. Import is additive and de-duplicates by CFI overlap within one class (core/markMatch.ts sameClass), so re-importing the file this library wrote adds nothing; a book the shelf does not have is reported by name. The class rule reached import on 2026-08-28 (WI-20.4): before it, one live bookmark — whose CFI spans the page — made every archived highlight on that page a duplicate, note included; archive rows that overlap each other within a class are kept as the later-made one and the fold is counted in the notice. ⚠️ A NAME-MATCHED BOOK'S ANCHORS ARE NOT TRUSTED, since 2026-08-31 (phase 21), and this row said "import is additive" without qualification until then. A CFI is a path through ONE package's spine and DOM; in another build of the same work that path is still VALID and addresses different words — it does not throw, it highlights the wrong sentence. So planImport partitions each shelf book's archive rows into id-matched and name-matched BEFORE any duplicate check, fold or card-body dedup (all three read anchors). ⚠️ AND AN ID MATCH IS EXACT ONLY BELOW 64 MiB — contentId samples above that, and 20 of the 1 959 books measured on 2026-08-31 are over the line, so an id match on a large book is strong evidence rather than proof. Cards were always exempt: Card.cfi is nullable, so a name-matched card imports with no anchor. The regression this row described is closed — it said "a name-matched book's marks are NOT imported … they now get nothing where they previously got marks", and named an unimportable list that no longer exists. WI-21.7 keeps them instead: a name-matched mark is stored with cfi: '' and an unplaced record — the only shape isMark accepts an empty anchor in — with its quote, context, note, tint and chapter intact, counted apart from marksAdded as unplacedAdded so the notice does not claim N marks were added and leave the reader to find that some go nowhere. Marginalia lists one with its jump disabled and "Paper has not found this passage here yet." The list is unplacedBooks, still a different sentence from unmatched. See Re-anchoring an unplaced mark, which is what retires that sentence. The Reader gained a notice surface in the same change (screens/Reader.tsx), because marks:import is offered from the reader screen and only the Library rendered the result — an import begun mid-book reported into a surface nobody could see
A mark that says it has no place Shipped core/marks.ts (Mark.unplaced), ui/pane/Marginalia.tsx A mark whose anchor cannot be trusted here is a STATE rather than a discard. cfi: '' plus { reason: 'foreign-build', fromBook } is the only shape isMark accepts an empty anchor in, so "anchorless" cannot be reached by forgetting a field. Marginalia draws the quote and the note, disables the jump, and says "Paper has not found this passage here yet."
Re-anchoring an unplaced mark Shipped ui/reader/reanchorPass.ts, ui/reader/reanchor.ts, core/reanchorCache.ts, ui/hooks/useReanchor.ts Import an archive from another build of a book, open it, and watch the unplaced marks become navigable. The passage is found in the RENDERED document by quote + 32 characters of context, so the CFI derived from it addresses the words it was derived from — right by construction rather than by repair, which is why route B was the only one of three the spike implemented. The hit is a store write (marks.place), not a render-time decoration: a mark resolved only in memory is re-resolved on every open and is invisible to export, to sync and to the browser client. Once per (book, marks-read) — forty cold sections is ~139 ms, fine after an open and not fine per page turn — and the cache remembers a MISS too, so a passage that is genuinely not in this build is not re-walked. An ambiguous match is refused rather than guessed at
Annotation search Absent — Search covers book text, not notes
Adjust an existing mark's extent Absent — A mark's range is fixed once made

Cards

⚠️ HIDDEN FROM ORDINARY READERS ON THE DESKTOP SINCE 2026-08-30. The kernel's side pane is behind developer options (⌘⌃⌥D) — see Developer options in Shell. The panel is drawn and does not yet answer what it promises, which is the property UNFINISHED_PANE_IDS names. State below still describes what the CODE does, because that is what this column has always meant; what changed is who is shown it. ⚠️ AND THIS SAID "EVERY ROW IN THIS SECTION IS BEHIND ⌘⌃⌥D" UNTIL 2026-09-11, WHICH IS FALSE ON TWO OF THE THREE SHELLS THIS DOCUMENT DESCRIBES. UNFINISHED_PANE_IDS is read by paneOffered, and paneOffered is a rule about the desktop RAIL. Neither src/app/mobile/ nor src/main.web.tsx mentions it, paneFits or developer at all — both mount TabBar, whose TABS is a literal four, and Cards is the third of them on every phone and in every browser client, permanently, with no chord to find. MobileApp.tsx renders the same ui/pane/Cards the desktop hides, out of kernel/ui/mobile; main.web.tsx renders it out of kernel/ui/browser and its own comment names the tabs "Library · Reading · Cards · You". The phone row in Other surfaces has said Four tabs — Library, Reading, Cards, Settings the whole time, so this document has held both readings since 2026-08-30 and nothing put them beside each other. ⚠️ AND THE CONSEQUENCE IS LARGER THAN THIS SECTION. AGENTS.md says removing an id from UNFINISHED_PANE_IDS "is the only edit required, because the rail, the palette, the digit accelerators and the Settings band all read one rule" — four surfaces, all of them the desktop's. Two shells read none of it. Shipping an unfinished panel is therefore not one edit and never was, and the reverse — hiding one — does not reach a phone.

Capability State Where How to confirm
Five card kinds Shipped core/cards.ts Idea, Claim, Recall, Synthesis, Excerpt
Make a card from a mark Shipped ui/pane/Marginalia.tsx The one place a note becomes a card
Cards panel with filters Shipped ui/pane/Cards.tsx, ui/pane/FilterChips.tsx Filter chips, empty states named. The chip row is shared with Marginalia — one component, so §07's "selected state announced as well as drawn" is fixed in one place
Spaced repetition / review Absent — Recall cards have a question and answer and nothing reviews them
Export and import Shipped core/marksArchive.ts (ArchivedCard), ui/marksFiles.ts, ⌘K → "Export your marks and cards…" ⚠️ THIS ROW READ Absent, WITH No writer anywhere FOR ITS CONFIRMATION, UNTIL 2026-09-10 — AND THE COMMAND HAS SAID "marks AND CARDS" THE WHOLE TIME. Cards ride the same archive as marks and are not a second document: ArchivedCard carries the kind, body, answer, source and createdAt, liveCards folds tombstones out before anything is written, and a book with no marks and no cards is left out entirely. localAnchor is nullable here and is not on a mark, because Card.cfi is already string | null and the Cards pane gates navigation on it — so a passage card imports usefully with no anchor where an anchorless mark would be refused. The Data section's Marks and cards export row has said this since it was written; two rows in one document disagreeing is what the coverage check in scripts/check-ledger.mjs now exists to make expensive
Capability State Where How to confirm
Table of contents Shipped ui/pane/Contents.tsx Nested; non-linking headings render disabled
Full-text search in book Shipped ui/pane/SearchPanel.tsx Streams hits, capped at 200, each navigable by CFI
Book switcher Shipped ui/overlays/BookSwitcher.tsx ⌘K → Switch book
Library shelf Shipped ui/screens/Library.tsx, ui/screens/BookCell.tsx, ui/screens/BookRow.tsx Grid or list, sorted by recently opened, title or author, with a count. Search field on the shelf itself. Every row opens; every row has a menu
Shelf search with operators Shipped core/searchQuery.ts, core/library.ts The field's own placeholder: Search — or tag:Name, is:reading to narrow. Also -tag: exclusion and is:untagged. Matches rows, not book contents — see Part 3
Real cover art Shipped core/coverArt.ts book.getCover(), written as a file beside the book's bytes. Never stored as data — a jacket base64'd into a record is tens of kilobytes in a row that syncs. The hash-derived colour is now the fallback, not the cover, and data/fixtures.ts is deleted
Virtualised shelf Shipped core/virtualGrid.ts Only rows near the viewport render. At 2 000 books the cost is not the DOM node but the cover hanging off it — read from disk, decoded, held as a blob URL
Background enrichment Shipped core/enrich.ts, ui/hooks/useEnrichment.ts An import writes a placeholder row whose title is the filename; a background pass then parses every book for its real title, author and jacket, without anyone opening it
A work line on the shelf Shipped core/ports.ts (WorkLine), src/capabilities/inference/index.ts The library status bar's third rung, and it says what is happening rather than computing it — a string, not a count pair. NO_WORK_LINE is the default, so a build with inference left out draws the two-rung ladder it always did, byte for byte
Passes that yield Shipped core/breath.ts Enrichment and sync's hash backfill are long and unwatched, and both stand aside for a reader scrolling a shelf or turning a page
Series shown Shipped core/bookMeta.ts, ui/screens/BookRow.tsx Series name and index render on the row. Not a sort order — the data is captured and only half spent
Import a folder Shipped core/importFolder.ts, ⌘K → "Import a folder…" Books are copied into the vault a few at a time — addMany, not a loop over add. 2 000 books in one synchronous pass put 2 000 write chains in flight and 82 of 1 959 silently failed; the count is now returned to the caller
Reopening a book Shipped ui/hooks/useBookIntake.ts, core/bookVault.ts Books are copied into $APPDATA/books/<id>/content.<ext> and opened from there. The picked path is kept but is not what an open reads, so a dialog scope expiring cannot break a reopen
Turning a page by gesture Shipped ui/reader/wheelPaging.ts, ui/reader/session.ts Swipe left/right on a trackpad, or roll a wheel, in paged flow. Horizontal is SPATIAL and resolves through goLeft/goRight; vertical is LOGICAL. One turn per gesture, and macOS's momentum tail is rejected rather than read as more gestures. Trackpad and wheel only — no touch
Gestures the book keeps Shipped ui/reader/session.ts A pinch (ctrlKey) passes straight through, and a wheel over an overflow: auto box the book's author made scrolls THAT box while it can still move, handing the gesture back at its end
Synthetic gestures refused Shipped ui/reader/session.ts The wheel listener requires isTrusted, so a book's own script cannot drive navigation or clear a selection
Pointer within reach of the page Shipped ui/reader/session.ts (commit addd117) The page to either side is clickable, not only the narrow gutter
Collections, tags, shelves Shipped core/library.ts, core/tags.ts, core/tagPrefs.ts, ui/screens/TagEditor.tsx, ui/pane/LibraryPanel.tsx A collection IS a tag. Editor with autocomplete over one book or a selection, ⌘-click selection with a bar, rename-merges, remove with a count and an undo, ⌘T from the reader. Fiction/Sea renders as a tree, tags take a pin and one of the three mark tints, publisher subjects sit in their own group with adopt and can be hidden, and a query can be saved as a row. Design: dev-docs/plans/phase-05-tag-management.md
Drag a book onto a tag Shipped core/bookDrag.ts Its own MIME type, so the window's file-drop handler can tell an internal drag from a book arriving from the Finder and does not light up the import state
Trash and undo Shipped core/bookTrash.ts, core/presence.ts Removal moves a folder to the trash, not away. The trash forgets after a fortnight; the presence register is an LWW register that outlives it, because a satchel in a drawer for a month must not resurrect a deletion
Mark as finished / unread Shipped ui/screens/BookMenu.tsx Per book, from either surface
Two-click removal Shipped ui/screens/BookMenu.tsx "Remove from library" arms; "Remove? — click again" confirms. Extracted at the second surface so the shelf card and the list row cannot drift
Relative then absolute dates Shipped core/relativeTime.ts "3 days" while that is the useful answer; an absolute date past a year, where nobody counts in months
Drop a book anywhere Shipped ui/hooks/useFileDrop.ts Including onto an open book, which would otherwise replace the app
Download a book this device does not hold Shipped src/capabilities/sync/index.ts (sync:download), src/capabilities/sync/lib/arrivals.ts A satchel's shelf carries rows whose bytes never came, and canOpen refuses them. Download is the honest seam on that row's menu — the repair rather than a re-import of the original file, which the reader may no longer have. ⚠️ THIS ROW IS NEW ON 2026-09-10 AND THE SURFACE HAS SHIPPED FOR A PHASE. It was found by scripts/check-ledger.mjs on its first real run, before it was wired into verify — the coverage check reported sync:download as a name the app declares and no inventory row contains
Evict this device's copy Shipped src/capabilities/sync/index.ts (sync:evict) Deletes the bytes here and leaves the book on the shelf and on every other device. ⚠️ CALLED EVICT AND DELIBERATELY NOT "REMOVE DOWNLOAD", because the table publishes remove with a contract — recoverable, to the trash — and this is not that act. content.evict, paper content evict and the cover cache all say evict: one concept, one word, in every place it is written. Also new on 2026-09-10, and found the same way
Where a book came from Shipped src/capabilities/sync/index.ts (sync:downloading, sync:arrived), src/capabilities/sync/lib/arrivals.ts (describeArrival) "Added from <device>" on a row that arrived over sync. Announced rather than gated — the shelf holds no approval queue — and it clears itself the moment the reader opens the book, so it is asked on every render rather than cached against an openedAt that can invalidate it underneath. At-or-after, not strictly after: a book opened in the same millisecond it landed has been seen, and the strict comparison left a notice that survived being read. Two statuses, one seam, both new rows on 2026-09-10
Library-wide text search Absent — The shelf searches rows, not contents. Nothing indexes the text of 2 000 books
Folder watching Absent — Was a stub that the UI advertised; the claim was deleted. Nothing watches a folder
OPDS / Calibre catalogs Absent — No network fetch of any catalogue format

Reading aids

Capability State Where How to confirm
Reading ruler Shipped ui/reader/ReadingRuler.tsx, ui/reader/rulerBand.ts, rulerOn Band tracks the pointer; Space pins and advances exactly one measured line. The band is drawn INSIDE the book document at §12's layer 0 — behind the text, which is unreachable from the host at any z-index
Read aloud, system voice Shipped ui/reader/speech.ts, ui/reader/useSpeech.ts Web Speech. Speaks the section in the language its document declares; the spoken word is followed on the page and turned to when it leaves it; a finished section goes on into the next until the reader stops it or the book ends. All three since 2026-08-28 (WI-20.9): before, every book was read by the English default voice, the page never turned in paginated flow, and speech stopped at every section boundary — every page, for a PDF. The turn against the real 300 ms animation is unmeasured. Not the same row as the neural voice under Companion — that one is Absent, and two rows called "Read aloud" with opposite states is how a reader stops trusting the table
Look up Shipped ui/lookUp.ts, core/gloss.ts, ui/reader/GlossStrip.tsx Select, then "Look up" on the selection bar. ONE BEHAVIOUR: it glosses the selection with Paper's own model. The three modes are deleted — the system-dictionary hand-off, kernel.lookUp, the settings cycle row and the Rust look_up command all went together, because macOS already offers Look Up on the right-click menu of any selection and Paper's copy was a second route to somebody else's window. both was worse than redundant: open raises Dictionary.app, so the default lookup spent a model run on a gloss behind a window the reader had left. This is a deliberate regression on macOS — a fresh install has no lookup until a model is downloaded — and it is not silent: with inference composed and nothing installed, the button stays and the strip offers the download (GlossProvider.installable). Where there is nowhere to install to — browser, iOS, Android — the button is absent, which is what Windows and Linux had before the gloss. Since 2026-08-30 no press is silent: an over-long selection said and did nothing at all — lookUpPress held the term bound and returned — and now draws the tooLong strip; and the download is offered only when installable read AT THE PRESS agrees, because decideLookUp reads it at the DRAW and a model uninstalled in between offered an install into a runtime that was not there (the WI-20.21 failure, through the one window it left)
Voice, rate, pitch control Absent — Whatever the platform picks
Translation Absent — No provider, no request, no setting
Reading statistics Absent — The pane was removed 2026-08-21 (phase 12, WI-12.5), and ⌘5 with it. It said plainly that nothing is measured — honest, and still one of eight slots on a 400px rail naming something that does not exist, which is the folder-watching case in a milder form. Nothing records a session; a session store is a phase, not a work item. Removed entirely on 2026-08-30, by decision — the last references went with it: a 22-line record of the deleted pane in ui/panes.ts, two comments in app/web/Reader.tsx, and a test title in ui/commands.test.ts that still said ⌘1…5 … and stats while asserting four rows. That title is the SECOND to outlive the panel it named, so the test now reads its own name and holds it to PANE_SHORTCUTS. Reinstating remains one row in ui/panes.ts plus one id in core/uiTypes.ts, the day something measures a session

Companion

⚠️ THE PANEL IS HIDDEN FROM ORDINARY READERS SINCE 2026-08-30 — THE SECTION IS NOT. ui/pane/Companion.tsx is behind developer options (⌘⌃⌥D), because it is drawn and does not yet answer what it promises, which is the property UNFINISHED_PANE_IDS names. State below still describes what the CODE does, because that is what this column has always meant; what changed is who is shown the panel. ⚠️ THIS SAID "EVERY ROW IN THIS SECTION IS BEHIND ⌘⌃⌥D" UNTIL 2026-09-11, AND IT IS FALSE OF HALF THE ROWS — INCLUDING THE ONE A READER USES MOST. Five of the ten below are reachable by anybody who opens the app: | Row | Where an ordinary reader meets it | |---|---| | The gloss | The selection bar's Look up — its own cell says "it is now the whole of Look up on every platform", and Look up is a Shipped row in Reading aids | | Local inference runtime | Settings → Local models | | Cloud endpoints | Settings → Cloud endpoints | | Faster / More thorough | Settings → Companion → Effort | | Read aloud, neural voice | Test voice, inside Settings → Local models | A capability's settings SECTION is not gated at all: Settings.tsx maps composition.settings into the The app band with no developer test anywhere near it — the only gate in that file is on the Developer band below it. So companion:provider, inference:models and inference:endpoints have been in front of every reader since they were contributed. UNFINISHED_PANE_IDS hides a PANE; it has never hidden a settings section, a selection-bar button or a Rust command.

Capability State Where How to confirm
Provider interface Shipped core/companion.ts, core/services.ts (companion:provider) bindCompanion is a kernel port held from birth; companion binds it and App reads services.companion()
Conversation view Shipped ui/pane/Companion.tsx, ui/hooks/useCompanionThread.ts Ask in the Companion panel. The reply streams, carries the amber tint, and stops on the composer's Stop. The question carries the reader's selection, and a failure says what failed — a signed-out agent, a missing one, an unsupported version — and writes one redacted diagnostic. Both since 2026-08-28 (WI-20.19, WI-20.18): the pane mounted the panel without the selection every docstring described, and every failure read "The companion could not answer"
Grounded answers with citations Shipped src/capabilities/companion/lib/passages.ts, ui/reader/passages.ts Paper numbers the passages, the model cites [n], Paper maps back. A [n] the table does not contain is DROPPED and flagged — never resolved to the nearest passage
Local inference runtime Partial src-tauri/crates/tauri-plugin-inference/, src/capabilities/inference/ (inference:models) Settings → Local models. Supervised lemond, per-launch bearer token, SHA-256 before activation. Since 2026-08-28: the whole llama.cpp backend is staged beside lemond from a pinned release under a per-file SHA-256 manifest verified before every spawn, and the daemon is told no_fetch_executables: true — nothing is fetched at gloss time (WI-20.24; it had been fetched from GitHub inside the first gloss with no hash Paper controlled); the daemon's process group is recorded at spawn and collected at launch and before every spawn, so a SIGKILL of Paper no longer orphans llama-server and its mapped model (WI-20.23); Install is offered only when the runtime is present (WI-20.21 — it used to land a 2.5 GB model that nothing could run). Partial: the runtime is staged by scripts/sync-inference-runtime.mjs and reports Not installed until it has been; one backend per platform (metal / cpu); and the network-off first gloss is unmeasured. ⚠️ THE STAGED EXECUTABLES ARE UNSIGNED, AND THAT IS A POSITION RATHER THAN A GAP — this row said "not yet codesigned" until 2026-09-05, which reads as pending work and is not. There is no intent to sign this app, upstream llama.cpp publishes neither signatures nor a codesign step, and Gatekeeper never looks at these files anyway: fetch() in a Node script sets no com.apple.quarantine xattr, so nothing evaluates a signature. The per-file SHA-256 manifest verified before every spawn is the whole guard, by design, and it is a stronger one — a signature says who built a binary, a digest says which bytes are about to run. The thing to protect is therefore the digest table, not a signing story
Codex / Claude routes Shipped src-tauri/crates/tauri-plugin-inference/src/agentask.rs One tool-free read-only turn per question, on the reader's own Codex or Claude subscription. The lockdown is flags, each with a test that fails when it is removed: no tools (--disallowed-tools "*", because the obvious --allowedTools "" does NOT disable them), no MCP servers, no hooks, no user config, read-only, one turn, prompt on stdin. Was Partial on a terms review of routing a consumer subscription through a third-party application; that review cleared on 2026-08-23
Faster / More thorough Shipped src-tauri/crates/tauri-plugin-inference/src/agentask.rs (Depth), src/capabilities/companion/ui/routesModel.ts Settings → Companion → Effort, offered only while an AGENT answers because the flags it maps to exist on the agent CLIs and nowhere else. Three states, and the default sends NO flag — the reader's own account setting, which they already pay for. Each adapter spends the choice on the axis its CLI offers, both measured: Codex on model_reasoning_effort (low returned reasoning_output_tokens: 0), Claude on a documented --model alias (sonnet reported claude-sonnet-5 with tools: [] intact). A CLOSED SET in Rust, so a caller cannot put a model id into an argv — an invalid one is refused by name
Cloud endpoints Shipped src-tauri/crates/tauri-plugin-inference/src/endpoints.rs (inference:endpoints), src/capabilities/inference/ui/endpointsModel.ts Settings → Cloud endpoints. Keys in the OS keychain, provisioned into the daemon's environment at spawn and registered with it on every start; the probe lists a registered endpoint as a route, resolve_model accepts it, and one the daemon refused is reported unusable rather than offered. There is no command that reads a key back, so the pane shows only whether one is stored — set, missing or, since 2026-08-28, unreadable: a key the keychain refuses (a macOS Deny, a dev rebuild whose signature no longer matches the ACL) is that one endpoint's problem, skipped at spawn while the daemon starts with the rest, where it used to stop the daemon and every lookup with it (WI-20.20). Removing takes two presses, because a key Paper cannot read is a key Paper cannot restore. This said Shipped once before while a reader could not add one at all — the four commands existed, were permitted, and nothing under src/ called them, so the keychain, the provisioning and the registration could never run in the app
The gloss Shipped core/gloss.ts, src/capabilities/inference/lib/glossProvider.ts, ui/reader/wordSnap/sentenceAt.ts Select a phrase → Look up. Defines the word in the sentence it sits in, amber, cached by term+sentence. It is now the whole of Look up on every platform, not one of three modes — see that row. The sentence is WALKED out of the document, not taken from the mark's stored 32-character window — and when it cannot be vouched for, the walk declines and the old window is used. A failed lookup is drawn apart from a successful one: never amber, never in the definition's position; so is a lookup with no model installed, which offers the download instead. Since 2026-08-30 it is bounded three ways it was not: a definition the model was cut off in the middle of is REFUSED rather than cached and drawn in amber as a finished one (generate::stream decoded finish_reason and dropped it, so max_tokens produced a whole-looking fragment); the request carries GLOSS_CEILING, 90s, rather than the companion's ten minutes; and the strip is taken down when the passage stops being shown — a page turn, a chapter, another book, or leaving the reader — where its × used to be the only route out. Also since 2026-08-30: the cache key IS the question the model is sent, because a key computed beside a request can disagree with it and this one did — it folded case, so March and march in one sentence were two questions with one entry; the fallback sentenceAround goes through sentenceOf with the completeness gate off, which fixes a segmentation that was a NO-OP on Chinese and erased the prefix at an abbreviation (it landed hardest on fixed-layout books, which never reach the walk); and which model answers is glossModel — the smallest installed text model, tie-broken by id — rather than whichever row the manifest left first. There is no picker because the manifest holds one text model, and a cloud endpoint is deliberately not a candidate: F8's cost-per-lookup argument applies to a metered endpoint exactly as it does to an agent
Read aloud, neural voice Absent src-tauri/crates/tauri-plugin-inference/src/commands.rs (inference_speak) Test voice proves a voice model and nothing else uses it. Sentence alignment and highlight-follows-voice are a separate phase. The system voice above IS shipped and is a different thing
Provenance treatment reserved Shipped core/marks.ts The companion kind, its amber tint and the wave style are reserved and unreachable by the reader, so a machine-written mark can never be mistaken for one of theirs

The circle

Composed on the desktop and offered to ordinary readers — it is not behind ⌘⌃⌥D, unlike Companion and Cards. composition.desktop.ts is [peer, sync, inference, companion, circle, publicSharing, webhost]; iOS and Android are [peer, sync, publicSharing] and the browser client composes nothing, so every row here is desktop-only. (Both lists gained publicSharing on 2026-09-09 and this note did not say so until 09-10 — the public layer has a section of its own below, and it is NOT desktop-only.) This whole section is new on 2026-09-05 and had no row before it. The design is in docs/design/circle/ — seven files, of which review.md is an adversarial pass that returned 28 objections and gated the whole of it until each was answered. ⚠️ THIS PARAGRAPH SAID THAT DESIGN WAS TRACKED UNTIL 2026-09-10, AND IT IS NOT. .gitignore line 71 ignores docs/, added by bd9c775 on 2026-09-04, and git ls-files docs/ returns nothing. wire.md and identity.md reach no clone, and the code comments that point at them name files which exist only on the machine that keeps them. ⚠️ AND .gitignore'S OWN ACCOUNT OF HOW THAT HAPPENED DOES NOT MATCH THIS HISTORY. Its comment says the circle's design "was tracked here for three commits and taken out of the index in the commit that added this line". bd9c775 changed .gitignore and nothing else — six insertions, one file — and git log --all --name-status -- docs/ returns no commit at all, so nothing under docs/ is tracked in the history this clone has. The likeliest reading is that those three commits went the way the gesture checklist did, in the history rewrite .gitignore records four lines above; that is a reading and not a measurement, and it is written here as one. ⚠️ AND THE FALSITY WAS LOAD-BEARING, WHICH IS WHY THIS IS NOT A TYPO. The sentence's whole purpose was to say these documents survive where dev-docs/ does not. The reason it gave — a key hierarchy is fixed the moment the first key is minted, and a wire format the moment a reader has data in it — is a genuine argument for tracking them, and nothing acts on it. It is left standing here as an argument rather than deleted, and it is a decision for the owner on its own terms; a documentation fix is not the place to reverse .gitignore line 71.

Capability State Where How to confirm
The circle, end to end Partial src/capabilities/circle/, src/kernel/core/circle/, src-tauri/crates/tauri-plugin-peer/src/circle.rs Share a passage on one machine and it appears, underlined, in the other reader's copy of the book, with no one doing anything. ✅ IT CROSSED ON 2026-09-07 — the first passage ever to reach another reader. A highlight published here through the real Share control was verified and stored on the second Mac as books/book_0c35da69…/circle/0e85cf15….json, same pub id, same passage; mbp16's own round said accepted: 1 refusals: 0. Transcript: dev-docs/plans/evidence/wi-24-c-the-field-with-two-names.md. STILL PARTIAL, AND THE REASON HAS CHANGED COMPLETELY — this row used to say the two-machine run had never happened, and that sentence is now void. It is Partial because the far end can publish only from the three books whose bytes it holds. ⚠️ AND IT SAID "because no run has yet covered the shelf and jacket half (WI-24.C3)" UNTIL 2026-09-11, WHICH HAD BEEN FALSE FOR THREE DAYS. WI-24.C3 CLOSED 2026-09-08 — dev-docs/plans/phase-24-the-evidence.md carries the heading in those words and the run under it: the per-person switch turned on from the Circle screen, circle/<us>/shelf.json landing on the far end with 1 962 works, and a jacket verified against its digest rather than merely present. Part 4's chain recorded that closure on 2026-09-10 and this cell did not, which is the same asymmetry the struck sentence below records, in the same cell, one pass later: Part 4 is read end to end when it is edited and a row is edited in place. The remaining limit is real and is the one now stated — three books with bytes out of 1 961, which is what constrained C3 and constrains anything after it. Both DIRECTIONS are proved as of 2026-09-08 — it published pub ff49a654873eac and the passage arrived here. scripts/circle-scenario.sh exists now and runs it unattended and repeatably — 19 steps, publish through the real Share control, converge on the far end, hold the person back and release them, then withdraw so the next run has a mark; --falsify stops the far end and proves the converge step can fail. ⚠️ THIS CELL CONTRADICTED ITSELF FOR A DAY, and the sentence is struck rather than quietly deleted: it went on "what is still unmeasured is the reverse direction: the far end has never published, which also leaves the 'file stays' half of the mute assertion with nothing to stay on" — written before the 09-08 crossing and left standing after it, three sentences below "Both DIRECTIONS are proved". Part 4 recorded the closure the same day; this row did not. Exactly the drift these passes keep finding, in its purest form: one cell, both states, nobody reading it end to end. That is a long way from sync's 22 steps in both directions, and this row is not to be read as if it were. ⚠️ AND THE RUN IS WHAT FOUND THE DEFECT — TWO OF THEM, EACH INVISIBLE UNTIL THE OTHER LANDED. SignedDelegation serialised its signature as signature while receive.ts demanded sig, so every page from a Rust-signing device was refused by every TypeScript verifier, before its signature was ever checked; and the rename could not reach a page already sealed, because a boundary keeps the delegation as first served. A green tsc, a golden vector over the signed BYTES, and a camelCase audit written for this exact class all passed throughout. Precisely the standing sync had for four phases, and precisely what it cost
Admission: two shelves pair Shipped src-tauri/crates/tauri-plugin-peer/src/pairing.rs, src-tauri/crates/tauri-plugin-peer/src/circle.rs, src/capabilities/circle/ui/PairingSection.tsx Offer, join, compare six digits — the same room and the same SAS a phone pairing uses, with kind: "circle" on the hello. This is what the design's own stated reason for mutual acceptance got wrong: the plan said mutual acceptance was accepted "because the alternative is a protocol Paper does not have", and mutual acceptance needed one too — pairing was shelf→satchel only and two authors are both shelves. a_shelf_dialing_as_a_satchel_is_refused_by_role still passes; two_shelves_complete_a_circle_pairing_and_still_compare_a_sas is the new one. A joiner and an offerer that disagree about the kind are refused, and an OLD shelf receiving kind: "circle" refuses it, which is correct. ⚠️ AND THIS ROW SAID SHIPPED WHILE THE THING DID NOT WORK FOR ANY REAL READER — measured 2026-09-08, and it is the sharpest example this document holds of what "Shipped" cannot see. Twenty protocol tests were green throughout, including the shelf-to-shelf one named above. On two Macs the pairing failed three times in four: dialled 60 s apart, which is what a person does, 1 of 4 got through (5 of 5 with the fix); dialled ~5 s apart, 10–11 of 12 did (12 of 12 with it). The warm figure is the artefact and the cold one is the truth. Dialling every five seconds keeps the path warm; real pairing is ALWAYS cold — two readers who have never talked, one offer, one dial — so any harness that loops without an idle gap reports the reassuring number. An earlier reading of this as "about one in six" was that mistake. The cause is a protocol gap, not a transport bug. The shelf sends NOTHING between receiving a hello and its human answering, so a joiner cannot tell you never heard me from your human is still deciding; a lost hello therefore leaves it holding a connection it believes is healthy, waiting on an ack that never comes — 150 seconds (confirm_timeout + ACK_GRACE) of a reader watching six digits, with no error for any retry to fire on. It now ASKS: after five seconds it opens a second connection beside the first and keeps both, and whichever is heard wins. Safe because the offer's secret is single-shot — one_hundred_concurrent_attempts_with_one_secret_pair_exactly_once already measured a far more hostile case. A rescued dial reports in 6.0 s; a warm one still reports in 1.5 s. ⚠️ THE COST IS TWO CONNECTIONS PER SUCCESSFUL PAIRING, the loser refused no-pending — so peer_status.droppedInbound climbing on the OFFERING side is the design, not a fault. Removing it needs the shelf to send an early received frame, which is an ALPN bump, deliberately not done. And usePairing now DROPS a result that is not about the attempt on screen: without that the losing probe's refusal cleared the six digits mid-comparison and told the reader the pairing had failed while it was about to succeed
Two pairing kinds, two grants Shipped src-tauri/crates/tauri-plugin-peer/src/circle.rs, src-tauri/crates/tauri-plugin-peer/src/events.rs A device pairing grants your own laptop the sync services; a circle pairing grants circle:read and nothing else. All five circle services are gated on it — hello, pages, shelf, lists, cover — so a paired DEVICE cannot ask for pages, which is the whole distinction the two kinds exist to make. ⚠️ THIS SAID "BOTH", AND THE SAME DOCUMENT SAYS FIVE TWO SECTIONS LOWER. ⚠️ AND THE GRANT IS NO LONGER THE WHOLE GATE (2026-09-12). circle:read is written once, at pairing, into the peer store — where it outlives forgetting a person, blocking them, exiting, and revoking one of their devices. Four of the five services now ask the caller's STANDING as well; pages was the one that asked nothing, so every passage this reader had shared was served to all four of those
Person identity: one root, many leaves Shipped core/circle/identity.ts Exactly one device holds the root key and its role is home, explicit rather than emergent. So revoking a device is a LEAF operation, and losing the home device is a succession rather than a revocation. Every signed object's version is (epoch, hlc) and never a counter: a restored identity cannot resume a counter it does not know, and two root copies could each mint "version 8"
The twelve words Shipped src/capabilities/circle/ui/IdentitySection.tsx The custody marker and the recovery phrase, shown only while a working device still holds it — a phrase offered before there is any context is a phrase that gets clicked through. A phrase read for one identity can never land under the next one's id
Share a passage Shipped src/capabilities/circle/ui/ShareControl.tsx (circle:share), src/capabilities/circle/lib/sharing.ts, src/capabilities/circle/lib/publish.ts A Share control on each of the reader's OWN marks, in Marginalia, through the kernel's markControls seam — not a second list of the reader's marks in a pane of the capability's own, which would be a second place to read the same book badly. Writes the passage into <book>/shared.json
A friend's passage, drawn in the book Shipped core/circle/overlay.ts, core/circle/foreign.ts, ui/reader/session.ts, overlay circle:shared It appears where the sentence is, not in a list. The capability answers with DATA and the kernel's own painter draws it — forBook may return neither a DOM node nor a paint callback, because the reader is the one surface this codebase keeps whole. Every annotation carries a ResolvedCfi, so a passage that did not anchor in THIS build cannot be handed over at all: the compiler refuses it at the boundary. ONE neutral hue for every FRIEND, never a palette — a colour per friend would compete with the reader's own three tints, where their meaning lives. Multiplicity rides on WEIGHT instead, so "4 of 11 readers marked this" is legible without a click. ⚠️ "EVERY READER" WAS TOO WIDE FROM 2026-09-09, when the public layer began handing this same painter a stranger's mark: there are two hues now, and which one is drawn is OverlayAudience — see "A stranger's mark does not look like a friend's". Within the circle the claim is unchanged. ⚠️ AND THE GROUPING USED TO THROW THE WORDS AWAY. Marks are grouped by anchor so four readers on one sentence are one heavier rule rather than four stacked — correct, and it kept only the FIRST row's note and attribution, so a second reader's words were parsed, admitted, anchored and silently dropped. ForeignAnnotation.opinions carries all of them since 2026-09-10, and people carries the identities the weight is counted from, because a count cannot be reconciled across two contributions and a set can
A foreign passage never enters marks.json Shipped core/circle/foreign.ts It lives at <book>/circle/<personId>.json — same folder, same write queue, same HLC discipline, so removing a book takes its foreign overlays with it. Inside marks.json it would be picked up by exportMarks, by the sync feed and by every one of the reader's own devices, as theirs
The wire: signed pages, sealed chains Shipped src/capabilities/circle/lib/protocol.ts, src/capabilities/circle/lib/publish.ts, src/capabilities/circle/lib/receive.ts, src/capabilities/circle/lib/crypto.ts, docs/design/circle/wire.md A page is signed by the device that served it under a delegation from the person; a recipient verifies before keeping a byte. A signed entry is never rewritten in place — a review edited is unreview + review, two entries and a new pub — because a page a friend already holds has to be reproducible byte for byte
A per-person, per-work budget, charged BEFORE the read Shipped core/circle/bound.ts, src/capabilities/circle/lib/spendLedger.ts 64 MiB per peer and 16 MiB per work in a rolling 24-hour window. charge reads, decides and commits in ONE synchronous step, so the round and a jacket fetch cannot each write back a total that forgot the other's charge — one budget, one ledger for the run
The fetch cadence Shipped src/capabilities/circle/lib/cadence.ts, src/capabilities/circle/lib/fetch.ts Half a minute after start, then every five minutes, one round at a time. ⚠️ IT SUBSCRIBES TO NOTHING — not the library, not the journal, not visibility — and that is the design. Pull-on-open leaks the reader's sequence to the peer: a fetch that starts when a book opens tells every friend which book that was. createSyncScheduler fires five seconds after each local commit and an open IS a local commit, which is exactly why the circle does not ride it. The falsifier is structural — there is no input here a book could reach
Mute, block, exit, re-admit Shipped core/circle/relationships.ts, docs/design/circle/relationships.md ⚠️ ADMISSION IS A NEGOTIATION; EXIT IS A DECLARATION. A block that needed the blocked person to acknowledge it would hand the decision to the one party who must not have it. Blocking is on the PERSON, not a device — a device-level block is a list you extend every time they buy a phone, and the phone works in the gap. Re-admission bumps an epoch, so a re-admitted person's old passages are not revived. A relationship record that will not read REFUSES that person's entries rather than drawing them: a decision about a person is not something to guess at. This is the ninth condition, which the review found and the original eight could not express
The reader's own opinion of a book Shipped core/bookFolder.ts (status, rating, ratingAt, review), core/serviceTable.ts (book.set), src/capabilities/circle/ui/OpinionEditor.tsx Want to read / Reading / Finished, one to five stars, and a review. On the RECORD, replicated by the ordinary sync, and publishing it is a separate act. ⚠️ THE ONLY UI THAT WRITES IT LIVES IN THE CIRCLE, so a build without circle — every phone, the browser client — has the fields, the service and the sync and no way for a reader to set them. ⚠️ AND IT IS A SECOND, UNCONNECTED NOTION OF STATUS: the shelf's is:reading / is:unread / is:finished is DERIVED in library.ts from finished and position and does not read this field at all, so a book marked Want to read is is:unread on the shelf and cannot be found by what the reader said about it
Publishing an opinion, per book Shipped src/capabilities/circle/lib/opinion.ts, src/capabilities/circle/ui/OpinionEditor.tsx One switch: "Share what I think of this book with my circle." status, rate and tag are last-writer registers on the wire and are emitted only when they differ from what was last published — republishing an unchanged value is a page a friend fetches for nothing. Turning the switch off publishes nothing more and withdraws nothing, and the copy says so
A friend's shelf Shipped src/capabilities/circle/lib/shelf.ts, core/circle/log.ts, src/capabilities/circle/ui/RosterSection.tsx A per-person switch, off by default, and with it on a friend can see the books you have. A log per person, not entries per work — the recipient asks about books it does NOT have, so it cannot ask per work. ⚠️ The shelf discloses title, author, identifier and language IN CLEAR, where the per-work log hashes them, because a recipient has to draw a title it has never seen. That is the whole reason it is behind a switch. bookId never travels: it is derived from the bytes of this copy
Lists Shipped src/capabilities/circle/lib/lists.ts, core/circle/list.ts, src/capabilities/circle/ui/OwnLists.tsx, src/capabilities/circle/ui/NewListForm.tsx, src/capabilities/circle/ui/ListMembership.tsx The reader's own lists, one log per list, published under the shelf's switch. The rows ARE the log verbatim — a retitle is a new row, a move a new place, a removal a tombstone — because a page a friend holds must be reproducible byte for byte
A friend's jackets Shipped src/capabilities/circle/lib/covers.ts, core/coverFacts.ts Fetched over the blob path under circle:read, chunk by chunk, charged to that person's budget, verified whole against the digest before a byte is kept, and held under the person's own folder — so a block takes the pictures with the pages. ⚠️ LAZY, AND NEVER IN THE ROUND: the round moves what is signed, and a friend with a thousand books must not cost a thousand transfers on a timer. LRU under a cap, 64 MiB by default
The circle's view of a book Shipped src/capabilities/circle/lib/circleView.ts, src/capabilities/circle/ui/CircleOfBook.tsx "2 of 3 gave it four stars or more", who else read it, and their reviews — computed locally from what this device already holds, with no new protocol. ⚠️ NEVER A MEAN, and sentencesOf is tested to contain no decimal. A 4.2 is what a site with a million readers says; three friends are not a sample, they are three friends. Every number is an integer count of people or of stars. A review from an older relationship epoch is not drawn
The Circle screen Shipped src/capabilities/circle/ui/CirclePane.tsx (circle:circle), ui/shell/TitleBar.tsx, core/capability.ts (ScreenContribution) A chip in the titlebar, on the shelf. A screen, not a pane, and it was a pane first — the side pane holds panels ABOUT what is already on screen, and the circle is peer to the library rather than about it; drawn in the pane it was a strip beside a grid of books it had nothing to do with. Four sections, each acting on its own: identity, pairing, the roster, the reader's lists. One shared busy meant a person who could not be removed was reported beside the offer link
Discovery, lurking, following a public author Absent — By decision, and stated so it is not mistaken for a gap. That is a publishing product — stable public address, no reciprocity, one-to-many fan-out, moderation, abuse handling — and none of it belongs in a peer protocol. Nothing is discoverable that you were not given, and nothing is multi-writer
A phone publishing to the circle Absent src/capabilities/peer/index.ts (serveWhenShelf) ⚠️ A CHOICE, NOT A CONSTRAINT, and the design flags that the plan presented it as forced. serve() in session.rs has no role gate at all — the restriction is TypeScript policy. The reason is that a publishing origin must be durably reachable and answerable for what it serves, and a phone is neither

The public layer

Composed on every platform that has a peer — composition.desktop.ts, composition.android.ts and composition.ios.ts all list publicSharing, and the browser client composes nothing. It is offered to ordinary readers and is not behind ⌘⌃⌥D. This whole section is new on 2026-09-10 and had no row before it. Phases 25 and 26 landed on 2026-09-09 — twenty work items, two ALPNs, a second signing key and a global index — and this document did not have a single row for any of it. The plans are dev-docs/plans/phase-25-global-sharing.md and dev-docs/plans/phase-26-public-annotations.md. ⚠️ THE CIRCLE AND THIS ARE OPPOSITE DEFAULTS AND SHARE NO AUTHORIZATION. The circle denies unless a person was admitted; this allows unless a voice is blocked. They ride two endpoints on two keys, and a single predicate serving both is where the wrong default would be invisible. ✅ IT CROSSED ON 2026-09-11. Eight public records published on this Mac were fetched by mbp16 and written into its own copy of the book, with the DHT off on both ends. scripts/public-scenario.sh runs it unattended — 15 steps — and --falsify stops the far app and proves the converge step can fail. Transcripts: dev-docs/artifacts/public-scenario-crossed.log and …-falsify.log. ⚠️ AND THE RUN FOUND THE DEFECT THAT MADE THE WHOLE DISPLAY HALF UNREACHABLE. writePublic writes books/<id>/public.jsonl, and the public capability had no entry in REVIEWED_WRITES — so every single record was refused by the namespace guard with "capability \"public\" may only writeAtomic under \"public/\"". The records arrived, the fetch reported them, the store stayed empty, and the pane printed the refusal as a trouble line nobody was reading. publicStore.test.ts supplies its own fs and share/acceptance.rs runs in one process, so neither goes through scopeFs and neither could see it. This is the third time a harness's first run produced a real defect — sync's satchel that never retried, the circle's 'muted' with no writer, and now this.

Capability State Where How to confirm
Publishing to strangers, end to end Shipped src/capabilities/public/, src/kernel/core/public/, src-tauri/crates/tauri-plugin-peer/src/share/ Offer a book by its hash, and anybody holding that hash fetches it — no admission, no roster, no reciprocity. ✅ OBSERVED BETWEEN TWO MACHINES ON 2026-09-11, DRIVEN THROUGH THE APP'S OWN CONTROLS. scripts/public-scenario.sh offers the book, publishes the notes service, publishes a passage, opens the same book on mbp16 and presses Look for some there — 15 steps, and --falsify proves the converge step can fail. Eight records crossed into an emptied store. ⚠️ THIS ROW SAID Partial AND "nothing has ever been end to end" UNTIL THEN, AND IT WAS RIGHT TO: the first run found that the receiving half could not write a single record, because public was missing from REVIEWED_WRITES. The named limit that remains is not this row's: see Discovery: hash → provider, which is the only route from a hash to a provider and is a global announce
A second endpoint, on a second key Shipped src-tauri/crates/tauri-plugin-peer/src/endpoint.rs, src-tauri/crates/tauri-plugin-peer/src/state.rs, src-tauri/crates/tauri-plugin-peer/src/share/mod.rs Two UDP ports in one process — 47821 for the circle, 47822 for sharing — on identity.key and share.key. Each REFUSES the other's protocols, and occupying 47822 leaves the circle working: the_two_endpoints_have_distinct_ids_that_survive_a_restart, each_endpoint_refuses_the_others_protocols, occupying_the_share_port_leaves_the_circle_working. The share endpoint is started by the first command that offers, withdraws, resolves or fetches — and at launch by resume_share when the policy file still holds an offer, which the row omitted until 2026-09-12. A reader who never publishes still never binds it, because resume_share returns early with nothing offered
contentHash is the network name Shipped core/publicShare.ts, src-tauri/crates/tauri-plugin-peer/src/share/topic.rs A book is addressed by BLAKE3 of its whole file, not by a bookId this device minted. Two readers who share only the hash compute the same DHT addresses from it, and the_derivation_is_pinned_so_two_builds_meet pins those addresses as the wire format they are — a derivation compared with itself cannot say two BUILDS agree, which is what the test did before 2026-09-10
Verified range transfer Shipped iroh-blobs 0.103 + bao-tree, src-tauri/crates/tauri-plugin-peer/src/share/mod.rs fetch_book A chunk is trustworthy AS IT LANDS rather than after the whole file. a_bad_provider_is_rejected_and_the_good_bytes_survive corrupts one provider's copy after import and requires the fetch from the honest one to still complete; a_book_lands_from_two_providers_between_them splits it across two
Serving is authorized per request Shipped src-tauri/crates/tauri-plugin-peer/src/share/policy.rs SharePolicy is per book AND per service, default off, and asked on EVERY request rather than per connection — serving_is_authorized_per_request_and_withdrawal_is_immediate withdraws a book on a connection that is ALREADY OPEN and requires the refusal. A policy file that will not read serves nothing and writes nothing, so a corrupt file cannot be overwritten by the next switch
A download may never inherit an identity Shipped core/publicShare.ts planShareImport The verdict IS the folder: bytes whose digest disagrees with a held book's go to pub_<hash> of their own rather than over an annotated book. A caller that got a boolean would be making the same judgement again somewhere else
A resource model for strangers Shipped src-tauri/crates/tauri-plugin-peer/src/share/bounds.rs, src-tauri/crates/tauri-plugin-peer/src/share/notes.rs, src-tauri/crates/tauri-plugin-peer/src/endpoint.rs Bytes per window, a machine-wide disk rate, concurrent transfers and connections. The rate is a shared reservation rather than bytes / rate per transfer, because the second gives every concurrent transfer the whole configured rate. ⚠️ AND THREE BOUNDS LIVE OUTSIDE bounds.rs, WHICH THIS ROW'S Where IMPLIED WAS THE WHOLE MODEL. MAX_REQUESTS_PER_CONNECTION (8) and CONNECTION_LIFETIME (120 s) in notes.rs — added 2026-09-12, because the serve loop ended only when the caller went idle, so a request every nine seconds held a connection slot for ever at no cost, and a refused request spends no byte allowance. Named limit, stated in the constant itself: the lifetime is unmeasured, because a test for it waits two minutes of wall clock and the only way to shorten it is a seam on the one function a stranger reaches. The third is endpoint::DIAL_TIMEOUT, now the crate's single dial deadline — the notes fetch previously dialled with none, so one unresponsive provider held every later candidate behind it
Discovery: hash → provider Partial src-tauri/crates/tauri-plugin-peer/src/share/dht.rs, src-tauri/crates/tauri-plugin-peer/src/share/topic.rs, src-tauri/crates/tauri-plugin-peer/src/share/index.rs, mainline 8.0.0 A BEP 44 mutable item at an address derived from the book's hash, plus announce_peer as an independent second signal. ⚠️ EVERY TEST IS AGAINST mainline::Testnet ON LOOPBACK, AND THAT IS A RULE RATHER THAN A GAP — an announce is publication into a global, unauthenticated, permanently queryable index, so no test may make one. What is therefore unmeasured is everything about the real network: whether the routers answer, what a lookup costs, whether a record survives its republish interval. ⚠️ AND THE INDEX IS A BULLETIN BOARD, NOT A ROSTER: its signing key is derived from the hash, so anybody who can read it can write it. Nothing downstream trusts it ⚠️ AND IT IS THE ONLY DISCOVERY THERE IS, WHICH IS WHY THIS LAYER HAS NEVER CROSSED TWO MACHINES — MEASURED 2026-09-11. ShareNode::discovered returns an EMPTY vector the moment dht() is None, so with announcing off a fetchNotes with no provider list can find nobody, on a LAN or anywhere else. The capability's own comment says "fetchNotes falls back to discovery itself when the list is empty" — and that fallback IS the DHT. mDNS advertises both endpoints (WI-25.8) and nothing in the public fetch path ever asks it to turn a content hash into a provider. So the layer offers exactly two states: announce to a global, permanent, unauthenticated index, or find nobody. That is a far better answer to why has this never left one process than "nobody wrote the harness": AGENTS.md forbids cargo test from touching the real DHT, and every other route supplies endpoints directly, which is precisely what share/acceptance.rs does. scripts/public-scenario.sh was built to close this gap and got 13 of its 14 steps green — offer, publish notes, publish a passage, open the same book on the far Mac, press Look for some — and the fourteenth cannot pass without an announce. peer_share_fetch_notes DOES take an explicit providers list, so the missing piece is small and is a product decision rather than a harness one: a way for a reader, or a harness, to name who to ask ✅ AND THERE IS A SECOND ROUTE SINCE 2026-09-11, WHICH IS THE WHOLE FIX FOR THE DICHOTOMY ABOVE. peer_share_id answers this device's share endpoint id — the public half of peer/share.key, so it reads a file and binds nothing — and the Publish pane shows it beside a field to name a device somebody gave you. lookForNotes carries that id through to fetchNotes, which has always taken a provider list, and it is tried BEFORE the index rather than instead of it, so the two compose. Out of band by design: a reader hands over an id the way they would a phone number, and the id says where to reach a device and nothing about which books it holds. That is what carried eight records between two Macs with announcing off. Still Partial, and the limit is now honest and narrow: there is no way to find somebody you were not told about without publishing your address to a permanent global index
Both endpoints are advertised on the LAN Shipped src-tauri/crates/tauri-plugin-peer/src/endpoint.rs Advertised Deliberate, and stated as a value rather than a log line so the claim can be read back. A stranger subscribing to mDNS sees TWO endpoint ids from this machine, their direct addresses and the first relay URL — under iroh's own service name, so a Paper install is not fingerprintable. Advertising only the share endpoint does not work: mDNS discovery is symmetric
Annotations are a separate service from bytes Shipped src-tauri/crates/tauri-plugin-peer/src/share/policy.rs (ShareService), src-tauri/crates/tauri-plugin-peer/src/share/notes.rs Offering opinions about a book is not offering the book. annotations_are_served_while_the_book_is_not, announcing_notes_does_not_make_the_book_downloadable, and an unoffered book answers exactly as one this machine has never held — a stranger must not be able to enumerate a library by asking
The voice: a second signing key Shipped src-tauri/crates/tauri-plugin-peer/src/share/voice.rs, core/public/envelope.ts peer/voice.key signs public envelopes and opens no connections; it is never derived from the person root, so a pseudonym is not the reader. It rotates, keeps the retired key while anything it signed can still be withdrawn, and the sequence is persisted before it is answered — a reused sequence is an equivocation by the fold's own rule
The public envelope Shipped core/public/envelope.ts, core/public/order.ts Canonical JSON, signed, 16 KiB, checked in the order that costs least to refuse — a stranger who can make this device verify a signature over malformed input has found a cheap way to spend its CPU. The bytes that were signed are what is STORED and relayed; there is no separate projection to go stale
Withdrawal, expiry and the replay horizon Shipped core/public/order.ts, core/public/envelope.ts Every envelope carries a signed lifetime (180 days by default, 400 at most), and a withdrawal outlives the note it suppresses — otherwise the evidence expires first and the withdrawn note comes back. Suppression is derived from the envelopes each fold rather than cached, so a withdrawal whose own sequence turns out to be equivocated stops suppressing
Publishing publicly is its own act Shipped core/public/publish.ts, src/capabilities/public/lib/publishPort.ts, src/capabilities/public/ui/PublishControl.tsx Never a mirror of a circle share and never a switch that forwards one. A two-step disclosure whose first state publishes nothing, and the acknowledgement resets after each publication — a flag that survived one would be the forwarding switch wearing a checkbox. The port checks the same rule at the boundary. When the same words are already in the circle the disclosure says so, because a separate signing key hides nothing there
Binding a voice to a person Shipped core/public/binding.ts, src/capabilities/public/lib/voicePort.ts, src/capabilities/public/ui/VoiceDecisions.tsx A local record that a pseudonym is somebody the reader knows. One voice, one person — a second claim is refused, because two people claiming one key is a claim at most one of them can support. Blocking wins over binding always, and blocking a person silences every voice bound to them
Silencing a voice, and hearing them again Shipped src/capabilities/public/ui/VoiceDecisions.tsx, core/public/binding.ts, src/capabilities/public/lib/publicStore.ts Silence this voice, Silence everything from them, Hear this voice again — per voice and per bound person, for this device and every book on it, not only the one open. ⚠️ NO ROW UNTIL 2026-09-12, AND IT IS THE READER-FACING HALF OF THE ROW ABOVE, which describes the BINDING and names the same file. ⚠️ AND THE REVERSAL WAS NOT ONE UNTIL THE SAME DAY. Silencing used to delete that voice's stored envelopes on the next write, so hearing them again revived a note they had withdrawn — the unnote that suppressed it was gone — and forgot an equivocation this device had already caught. Their withdrawals and both sides of an equivocation now survive; what a silencing still discards is a live publication, which is the thing the reader asked not to see and the thing that occupies the book's retained room
No public aggregate Shipped core/public/presentation.ts, src/capabilities/public/lib/overlay.ts "4 of 11 readers marked this" is a sentence an attacker writes when keys are free. So an unbound voice is reported as a FLAG and never as a number, one free key and a thousand produce the same answer, and reconciliation runs BEFORE any weight — a voice bound to a person folds into that person's circle mark and the pair counts once. The overlay host is where that fold happens, because it is the only thing that sees both channels
A stranger's mark does not look like a friend's Shipped core/circle/foreign.ts (OverlayAudience, overlay public:annotations), ui/reader/session.ts, ui/reader/bookCss.ts Same shape — a rule under the words — in a quieter hue: palette.stranger against palette.foreign. ⚠️ IT WAS DRAWN AS A FRIEND'S UNTIL 2026-09-10. The public capability said "stranger" by prefixing the person id, the overlay host dropped everything but the key and the count, and attachForeign labelled every one of them with the circle's painter kind. Provenance a reader is meant to SEE cannot live in a string prefix only the producer reads
Bounds against a flood Shipped core/public/bounds.ts, src/capabilities/public/lib/publicStore.ts, ui/reader/reanchorPass.ts The adversary is stated: unlimited keys, unlimited publication ids, a fast uplink, and the aim of making the reader's own writing slow or fail. What is defended is the READER'S work — opening a book, editing a private note, cancelling a load; what is conceded is that a flood can fill the public quota, because a bulletin board anybody may write to is one anybody may fill. Verification, re-anchoring and selection all yield and are all cancellable
Receiving somebody else's notes Shipped src-tauri/crates/tauri-plugin-peer/src/share/notes.rs (ask_one), src-tauri/crates/tauri-plugin-peer/src/share/mod.rs (fetch_notes), peer_share_fetch_notes, src/capabilities/public/lib/publicStore.ts (writePublic), src/capabilities/public/ui/PublicPane.tsx Ask whoever serves a book for its public annotations; what comes back is unverified bytes and goes straight into the door that checks the signature, the book, the expiry, the reader's block list and the storage caps. ⚠️ THE BLOCK LIST APPLIES TO A PUBLICATION ONLY, SINCE 2026-09-12. A silenced voice's unnote is admitted deliberately: suppression is keyed to the voice's own publications, so refusing one leaves the note it took back standing, which makes the reader hear MORE of somebody for having silenced them. ⚠️ THIS HALF DID NOT EXIST UNTIL 2026-09-10, AND ITS ABSENCE MADE THE WHOLE DISPLAY SIDE UNREACHABLE. The plugin had answered paper/share-notes/1 since the phase landed and no device ever asked, so writePublic — the only thing that fills the store the overlay reads — had no production caller at all. ⚠️ THIS READ Partial UNTIL 2026-09-12, ON A REASON THAT WAS ALREADY VOID: "nothing has driven it in the app". scripts/public-drive.mjs presses Look for some in the running app over the MCP bridge, scripts/public-scenario.sh runs it on the far machine, and the row two above this one records eight records crossing between two Macs on 2026-09-11 — driven through the app's own controls. One edit flipped that row and left this one saying the opposite, which is two rows of one document disagreeing about one run. one_paper_can_ask_another_for_a_book_s_notes still drives the protocol between two live endpoints in one process. Asking is on a control and never on a timer, because asking tells whoever answers that this device is interested in this book
The Publish pane Partial src/capabilities/public/ui/PublicPane.tsx (pane public:book, mark control public:publish), src/capabilities/public/ui/VoiceDecisions.tsx, src/capabilities/public/lib/publicPort.ts Open a book; the Publish panel in the reader's side pane offers two switches drawn apart — the file itself, and notes about it — because offering a book's bytes and publishing an opinion about it are different acts with different consequences, and the common case is the second without the first. The control is ABSENT with the reason present rather than disabled with none. ⚠️ AND SINCE 2026-09-12 IT DRAWS A THIRD THING ABOVE BOTH SWITCHES, BECAUSE IT CONTRADICTS THEM: "Nothing below is being served: this device could not start sharing when it launched." The switches are read from the policy FILE and the endpoint is a process, so with the endpoint stopped every switch goes on saying "offered" over a device serving nobody. Rust had emitted paper://share-resume-failed since it was written and nothing on this side had ever listened. Partial on ONE of its two original grounds: no capture. The other — "nothing has driven it in the running app" — was void when written; scripts/public-drive.mjs opens this pane and clicks Offer to anyone, Publish notes, both withdrawals and Look for some
Following a public voice, a feed, a directory Absent — By decision, and the same decision the circle's row records. Nothing is discoverable that you were not given a hash for, and a voice has no address a stranger can subscribe to

Shell

Capability State Where How to confirm
Command palette Shipped ui/overlays/CommandPalette.tsx, ui/commands.ts ⌘K. Commands are stateful and name what they will do — "Larger type — 23px", "Hide the progress rule", "Remove this bookmark"
Keyboard map Shipped ui/accel.ts, ui/panes.ts PANE_SHORTCUTS ⌘4 IS DEAD UNLESS DEVELOPER OPTIONS ARE ON — Cards is unfinished, and a digit that opens a panel the rail does not draw is the mirror of a rail button that opens nothing, so paneFits answers for both. ⌘⌃⌥D toggles developer options and is bound to event.code, not event.key: a real ⌘⌃⌥D arrives as { key: 'd', code: 'KeyD' } — measured in the running app, against a first version that guessed macOS would rewrite the character under Option. ⌘1–⌘4 panes (Contents, Marginalia, Search, Cards — Companion, Library and Settings carry no digit, and the digits are the order the panels were PUBLISHED in, not the rail's order, so renumbering would move ⌘3 off Search), ⌘\ pane toggle, ⌘K palette, ⌘L reader ⇄ library, ⌘D mark the selection in the tint and style the bar is showing (and left to the browser when there is no selection, rather than swallowed to do nothing), ⌘B bookmark, ⌘T tags, ⌘+/⌘−/⌘0 size, arrows and PageUp/PageDown to turn, Space a screen forward and ⇧Space back, Esc. They work from inside the book iframe. ⇧Arrow selects rather than turning; Space yields to the ruler when it is on
Side pane, 8 kernel panels, either side Shipped ui/pane/SidePane.tsx, ui/panes.ts, core/uiTypes.ts (KERNEL_PANE_IDS) Contents, Marginalia, Search, Cards, Companion, Library, Settings, Developer. Rail membership checked at compile time. EIGHT IS THE KERNEL'S OWN COUNT, and since 2026-09-04 it is no longer the whole rail: a capability contributes panes of its own under a <capability>:<name> id — the colon is what tells the two apart at runtime, and no kernel pane has one — so circle adds circle:book beside an open book. A contributed pane names the screens it belongs on; a remembered pane naming a capability that is no longer composed is answered for at runtime, which is why KERNEL_PANE_IDS is a value and not only a type. FIVE OF THE EIGHT ARE OFFERED BY DEFAULT since 2026-08-30: Cards and Companion are unfinished and Developer is the switch that reveals them, so an ordinary reader sees Contents, Marginalia, Search, Library and Settings. One rule answers for the rail, the palette, the digits and the reducer — paneOffered, folded into paneFits rather than left beside it, because §11's pane ids had already drifted across three copies once. Was seven, and the count in this cell is the reason it is spelled out rather than left to the list. When the grid cannot pay for a track of its own it becomes a sheet over the reader rather than refusing to open — decided by MEASURE, against what the reading step's column costs, not by a flat width: PANE_COLLAPSE_W = 1024 is gone (core/metrics.ts says why; the numbers are in dev-docs/pane-collapse-threshold.md), and this row said "below 1024px" for a week after it went. Was eight: Reading went with ⌘5 on 2026-08-21 and this row did not, so the document said eight in one place and explained the removal in another
Developer options Shipped core/uiTypes.ts (UNFINISHED_PANE_IDS, paneOffered, hiddenPanes), ui/accel.ts, ui/pane/Settings.tsx, ui/pane/DevPane.tsx ⌘⌃⌥D, and nothing else. There is no control anywhere that turns it on: a switch a reader can find is a switch a reader will find, and what it reveals is a set of panels that do not answer what they promise. On it: the unfinished panels appear with a switch each in a Developer band in Settings, and the Developer panel shows the diagnostics window this run recorded — the surface diagnosticsLog.ts was written for and never got ("a dev pane that wants the window, and a harness over ssh that wants the file" — the file half shipped in phase 8, this is the other). Newest first, filtered by level and by scope prefix; it does not tail, because a panel that moves while it is read is a panel nobody can read, and it cannot turn recording on, because that is a FILE decided at boot before the services that hold settings exist. The state persists (kernel.developer), which took a second pass to be true — it was missing from the write effect's dependency list, so it worked and did not survive a relaunch
What a capability may contribute Shipped core/capability.ts, core/circle/overlay.ts Ten seams, and each is a place a capability adds to the app without the kernel learning what it added: panes, screens, commands, settings, bookActions, bookStatuses, markControls, services, clients and overlays. The last three are the newest — markControls puts a control on each of the reader's own marks (the circle's Share), and overlays hands the kernel DATA to draw in the book. ⚠️ NEITHER panes NOR overlays LETS RENDERING INTO A CAPABILITY: a pane renders into a slot the kernel owns, and an overlay answers annotations rather than a DOM node or a paint callback. That line is what keeps the reading surface whole
Marginalia filters Shipped ui/pane/Marginalia.tsx All, Marks, Notes, Bookmarks, Companion — as icons on one line. Navigating to a mark the current filter hides resets the filter rather than showing nothing
Floating surfaces stay on screen Shipped core/placement.ts One decision, one place. The book cell's menu and the selection popup each used to compute their own position, and the menu hung off the window in the first column
Chrome fade Shipped ui/shell/TitleBar.tsx Fades in the reader, returns on pointer-near
Platform titlebars Shipped ui/shell/TitleBar.tsx, src-tauri/src/lib.rs, src-tauri/tauri.conf.json macOS: the overlay titlebar with the real traffic lights. Off macOS the app drew two — one window config carries decorations: true and the macOS-only titleBarStyle: Overlay, so TitleBar.tsx drew its own controls BENEATH the OS caption, close button hundreds of pixels from the corner; the quit menu item was macOS-only and the tray had no menu, so the only quit was the window's close. WI-20.33 landed 2026-08-28, commit 26ed374: set_decorations(false) off macOS at window creation, Ctrl+Q bound off macOS and running the window's OWN close sequence rather than a bare exit, and a tray menu whose Quit goes through the same handshake. ⚠️ This row still read "is the fix and has not landed" on 2026-08-29, four commits after it did — the drift these re-audit notes keep describing, caught this time by someone reading the row aloud rather than by any gate. Partial is now about what has not been OBSERVED, not about missing code. ✅ The Linux cargo check has run and passed — 2026-08-29, PR #1, ubuntu-24.04, the full 19-step gate green in 5m58s, which is cargo metadata --locked, fmt, clippy -D warnings and test --workspace on Linux for the first time in the project's life. Getting there took five fixes and turned up two Linux-only Rust defects clippy could only see there (an unreachable match arm where ENOTSUP and EOPNOTSUPP are the same number, and books_in_urls dead off macOS). ✅ And the Windows leg is green too — the first run of this project's JS suite on Windows found 38 failures across thirteen files, all of them things the other two platforms cannot see (FlushFileBuffers needs a write handle, so fsync on an 'r' handle failed on every file and took every CLI write with it; zip and unzip do not ship there; NTFS has no execute bit; a process's working directory is an open handle; tar may be Git's GNU one, which reads D:\… as a remote host). Measured on a real Windows machine as well as on CI. ✅ And the screenshot exists — 2026-08-29, dev-docs/artifacts/linux-titlebar-{A-fixed,B-control}.png, captured under Xvfb + openbox on an aarch64 Ubuntu 24.04 box. A PAIR, from one tree with one line different, because a single image showing one titlebar is equally consistent with a window manager that never decorated anything: B has the set_decorations(false) block commented out and shows openbox's caption above Paper's own bar, two sets of window controls. _NET_FRAME_EXTENTS reads 1, 1, 20, 5 for B and 0, 0, 0, 0 for A, which is the same fact without the pixels. This row is Shipped
Book content cannot reach native Shipped src-tauri/tauri.conf.json, core/rendererIsolation.test.ts, ui/reader/bookScripts.ts, scripts/make-hostile-epub.py, scripts/csp-effect.mjs, dev-docs/artifacts/renderer-isolation-verdict.png ✅ THE LIVE CHECK RAN FOR THE FIRST TIME ON 2026-09-11, AND IT PASSED. The hostile EPUB was opened in a running debug build on a scratch data root, driven by argv (opened::books_in_argv, lib.rs:706). The book rendered — title, prose, a TOC entry, 100% — and #paper-isolation-verdict still read its default SCRIPT DID NOT RUN. Capture: dev-docs/artifacts/renderer-isolation-verdict.png. A false pass was ruled out three ways before the result was believed: the script PARSES (node --check), every probe is try/catched ("refused counts as safe"), and the write that would overwrite the paragraph is the script's FINAL statement — so a script that ran at all could not have left that text. ⚠️ AND THE MECHANISM IS NOT THE ONE THIS ROW NAMED FOR A MONTH. Read out of the book's own document through renderer.getContents(): scriptCount is 0. A CSP blocks EXECUTION and leaves the element in the DOM; the element was gone. ui/reader/bookScripts.ts strips scripts at the loader, and it is what actually stopped this book. The row said "CSP with script-src 'self'" as its whole confirmation. So the CSP was never exercised by the fixture at all, and while stripping works it never can be: on the desktop make-hostile-epub.py can only ever return SCRIPT DID NOT RUN, which means it cannot tell its two layers apart. Testing the CSP end to end needs stripping disabled first. ⚠️ AND THE TWO DEFENCES ARE NOT TWO, AGAINST THE THING THAT MATTERS. Measured from inside the book: origin http://localhost:14201 — the app's own — sandbox allow-same-origin allow-scripts, window.frameElement reachable. window.__TAURI__ absent and parent.__TAURI__ absent, so withGlobalTauri: false does what it says. But parent.__TAURI_INTERNALS__ and top.__TAURI_INTERNALS__ are PRESENT — that is the IPC channel, and the fixture counts it as a breach in its own list. So a book whose script ran would reach invoke through its parent, and the only things between are the strip and the CSP. withGlobalTauri off removes the convenience global and not the channel; this row's phrasing implied a second wall where there is a second lock on the same door. ✅ scripts/csp-effect.mjs RAN TOO, AND PASSES WITH ITS OWN CONTROL — strict blocks inline, external and framed in BOTH WebKit and Chromium; the deliberately widened policy reports RAN on all three, which is what proves the probe can fail. ⚠️ It measures paper-webhost's policy, not tauri.conf.json's, so it is the BROWSER client's wall it weighs, and nothing in verify or CI runs it — it is cited in five comments and invoked by none. The browser half of the fixture (the credential probes, and the script-free framing probe, which needs --origin) is still unrun
Liquid Glass app icon Shipped src-tauri/icons/Paper.icon/ macOS 26 layered icon
Bundled font licences travel with the build Shipped scripts/lib/notices.mjs, scripts/write-third-party-notices.mjs, src-tauri/tauri.conf.json THIRD-PARTY-NOTICES.md at the repo root, shipped as a bundle resource. Four typefaces ship in every copy, all four under SIL OFL 1.1, which permits redistribution only if each copy carries the copyright notice and the licence text. Neither travelled, so every build relied on terms it did not meet — and nothing surfaced it: no test failed, no build broke, the app worked. The notice is generated from what is actually installed, shipped as a bundle resource, and gated three ways: it matches the installed packages, its package list matches what src/main.tsx imports, and it is present in the bundle

Other surfaces

Paper is three front ends over one kernel, and until 2026-09-05 this ledger described only the first. Each has its own HTML entry and its own composition root: index.html → src/main.tsx → App (the desktop shell), index.mobile.html → src/main.mobile.tsx → MobileApp (the phone), and index.web.html → src/main.web.tsx (the browser client). vite.config.ts picks between the first two from TAURI_ENV_PLATFORM.

Capability State Where How to confirm
The browser client Shipped src-tauri/crates/tauri-plugin-webhost/, src/capabilities/webhost/ (webhost:browsers), src/app/web/ Open the shelf's address on a phone or another laptop's browser and read a book off it, over loopback or Tailscale. Six digits typed from the screen are the whole auth decision; after that a cookie and a WebSocket carrying the same envelope to the same router the CLI and the peer use. A reader, and the desktop side pane as a sheet — four tabs, highlighter, search, list and settings — plus marks, positions, covers and PDF ranges
The browser session's grant is deliberately small Shipped src/capabilities/webhost/lib/pump.ts The kernel table's READS plus book.position, and nothing else — and book.position is refused for any book but the open one. An earlier version granted everything, and a phone that signed in once could empty the library. ⚠️ THE GRANT CANNOT SIMPLY BE WIDENED: a hostile EPUB renders in the shelf's own origin and would carry the same ambient cookie, so a writing remote client needs a credential class the server does not have, not a different transport
Tap to turn Shipped src/app/web/tapToTurn.ts A phone has no wheel — Playwright says so outright, and it is the device rather than the harness — so the browser client shipped a reader that opened a book and could not advance it, measured by trying to turn a page, which no test in this tree could have done. Outer thirds turn; a tap with a selection, on a link, or after a drag does not
The phone runs its own shell Partial index.mobile.html, src/main.mobile.tsx, src/app/mobile/MobileApp.tsx, src/app/shell/ pnpm ios / pnpm android. Four tabs — Library, Reading, Cards, Settings — over the device's OWN library on its own disk, with the real services and the right to write; the browser client mounts the same furniture over a socket instead. Until 2026-08-30 iOS rendered the DESKTOP shell, traffic lights and all, because there was no second entry to render. ⚠️ Partial, and the named limit is the whole reason the app exists: THERE IS NO READER YET. MobileApp passes hasBook={false} and the tab bar redirects Reading to Library — mounting the reader is what turns that tab on. So a phone today is a shelf, a card deck and a settings screen. What that limit costs is smaller than it reads — see A phone reading a book below, whose Where now names the reader that already exists and the four contracts it is parameterised over
The mobile design was not written twice Shipped src/app/shell/ The tab bar, the Continue strip, the bottom sheet, the selection bar and the progress footer were built inside src/app/web/ for the browser client and MOVED UP rather than being reimplemented. Same components, different wiring, and no branch inside either. The phone uses the desktop Library for the same reason — it already carries search, tag chips, sort, the row menu and the virtualiser that makes 1 961 rows scroll, and a second implementation would begin by being simpler and end by being those things again, minus the fixes
A phone reading a book Absent src/app/web/Reader.tsx, src/app/shell/, src/kernel/ui/mobile.ts See the phone row. ⚠️ STILL ABSENT, AND THIS ROW'S Where WAS AN EM DASH UNTIL 2026-09-06 — WHICH READ AS "NOTHING EXISTS", AND MOST OF IT DOES. src/app/web/ already holds a COMPLETE, TESTED, PHONE-SHAPED READER: Reader.tsx is 822 lines mounting the same FoliateView the desktop mounts, with useTapToTurn (352), useMarking (157) and useBookSource (117) beside it, and its furniture — BottomSheet, ProgressFooter, SelectionBar — is ALREADY in src/app/shell/, put there for sharing by the very move this row is waiting on. MobileApp.tsx hardcodes hasBook={false} at one line. The reader is parameterised over TYPED INTERFACES, not over the socket — RemoteContent, MarksStore, ReadingPositions, RemotePositions — so this is the same operation the other five components went through, and the row above records that precedent. What is genuinely missing is the DATA SIDE: local implementations of those four contracts over KernelServices where the browser client's content.ts (377), marks.ts (399) and positions.ts (264) speak over a WebSocket, plus the reader's surfaces added to ui/mobile.ts one at a time with their wiring, per that door's own rule. ⚠️ native-root-not-browser-ui-entry (dependency-cruiser, severity error) refuses a native root ui/browser.ts, so the shared surfaces have to reach the mobile door rather than the phone borrowing the browser's. Absent is still the right state — nothing mounts a reader — but the distance is a rewiring against settled contracts, not a design. ✅ THE DATA SIDE LANDED 2026-09-06: src/app/mobile/localContent.ts, localPositions.ts and localMarks.ts answer all three contracts off this device's vault, with 20 tests — including the two decisions the writing forced. Positions go on the RECORD, not in localStorage, so positionAt stamps them and sync replicates them; kept in browser storage a phone would read on the train, arrive home, and find the laptop had never heard of it. And 'fill' is the only style this surface can produce — MarkDraft carries a tint and no style, and the phone's SelectionBar offers three tints and no style picker — so reading one out of a setting would invent a decision the reader was never given. ⚠️ WHAT REMAINS IS ONE ARCHITECTURAL FORK, not more adapters: Reader.tsx imports its kernel surfaces from ui/browser.ts, and shared-shell-kernel-entries forbids src/app/shell/ — the only directory both roots mount — from naming ANY UI door, "so importing one would pick a platform on the other root's behalf". So the reading screen either moves into src/app/shell/ with its kernel surfaces named as permitted leaves, the way that rule already permits OverlaySheet and BookCover, or it moves into the kernel beside the desktop's own ui/screens/Reader.tsx and both doors export it. Either changes the browser client, which is why it is a decision and not a task
Touch gestures in the reader Unknown inherited from foliate-js foliate handles touchstart/move/end, so a touchscreen is at least modelled; wheelPaging.ts is the trackpad and wheel half and says so. Never tested here — the desktop has no touchscreen and the phone has no reader

Data

Capability State Where How to confirm
A book is a folder Shipped core/bookFolder.ts, core/bookIndex.ts $APPDATA/books/<bookId>/ holds book.json, marks.json, content.<ext>, cover.jpg. One place per book, so a copy without a row and a row without a copy are both unrepresentable
Local persistence Shipped core/fileStore.ts, ui/appStorage.ts Written through a temp file and a rename. localStorage is COPIED on first run, not moved. A corrupt store is renamed aside rather than overwritten by the next mark
Quit closes the journal Shipped src/app/shutdown.ts, src-tauri/src/lib.rs macOS fires no window-close for ⌘Q, so the app never closed its sync journal on quit and every quit left it dirty. There is a real handshake now — the native side asks, the webview tears down and answers, and the quit proceeds either way after a timeout that says in the log that the journal may be left dirty. It lives in src/app/ rather than in Rust so that it can be tested
Content-derived book identity Partial core/contentIdentity.ts (FULL_HASH_LIMIT, SAMPLE_BYTES, INTERIOR_PROBES), core/marks.ts (contentId) SHA-256 over the whole file up to 64MB (FULL_HASH_LIMIT). Above that it samples 64KB at each end (SAMPLE_BYTES) and 16 interior probes (INTERIOR_PROBES), and a change confined to a gap between probes is invisible. ⚠️ THE Where CELL NAMED ONLY core/marks.ts UNTIL 2026-09-11, AND THE GEOMETRY THIS ROW DESCRIBES IS NOT THERE. marks.ts re-exports the three constants in one line and says so — "THE SAMPLING GEOMETRY MOVED TO contentIdentity.ts" — so the claim resolved to a file that would tell a reader nothing about the limit the row is Partial for. A Where cell that lands on a re-export passes every path check there is
Identity migration Shipped core/idMigration.ts, core/migrateToFolders.ts bookIdFor changed shape twice; a reader's existing marks, cards and rows are carried across rather than orphaned
Work identity Shipped core/bookMeta.ts, core/bookFolder.ts (BookRecord.identifier, record version 1), core/circle/workClaim.ts Was Stub, and "nothing reads it" was the smaller half of the error — dc:identifier had been PARSED for years and was never written to the record, so the one field that says which work a book is did not survive an open. It is on the record now, and the circle reads it. ⚠️ A scalar workKey CANNOT name a work across builds and this repository proves it against itself: workKey.test.ts has a test called "keeps the corpus's three builds three different works" whose own comment says it is wrong — three real builds of Moby-Dick declare a Gutenberg URL, a Standard Ebooks URL and an ISBN. So a work is a claim set compared by INTERSECTION: strong on a shared id, weak on language + author + a shared title spelling. language is required, because Moby-Dick in English and in Chinese translation share a title, an author and often an identifier, and share no passages at all. Everything but the language is hashed, since an opaque dc:identifier can carry a licence URL with an email in it
Re-anchorable annotations Shipped ui/reader/reanchorPass.ts, ui/reader/wordSnap/markContext.ts Was Stub — "a mark stores the 32 characters either side of its quote; nothing re-anchors with them yet". Something does. See Re-anchoring an unplaced mark under Annotation
Tag export and import Shipped core/tagArchive.ts, ⌘K → "Export your tags…" / "Import tags from a file…" Versioned document. Only what the reader made — publisher subjects are excluded, because they regenerate from the book on every parse and importing them would forge provenance. Pure: matching a book in the file to a book on the shelf is decided in one testable place
Marks and cards export Shipped core/marksArchive.ts, ui/marksFiles.ts See Annotation export under Marks. The reader can take their marginalia with them
Settings → Storage, and Settings → Devices Shipped src/capabilities/sync/index.ts (sync:storage), src/capabilities/peer/index.ts (peer:devices) The two bands sync and peer contribute to Settings. Storage is what this machine is holding — which books' bytes are here, what the covers cost, how the last session went. Devices is the machines it talks to. ⚠️ NEAR BEFORE FAR, AND THE ORDER IS DECLARED RATHER THAN INHERITED. A reader at the bottom of Settings is far more often asking what is this using? than what else is paired?, so the first answer is not put behind the second; both declare a number (10, 20) so neither moves when the other changes its mind, and unordered they fell out of composeCapabilities, which is topological by requires. ⚠️ NEW ON 2026-09-10, AND THE LEDGER HAD NEVER MENTIONED EITHER BAND — Devices appeared zero times in this document while the Sync row below described the transport underneath it in two thousand words. Found by scripts/check-ledger.mjs's coverage check
Where do your books live? — the shelf/satchel choice Shipped src/capabilities/peer/ui/DevicesPane.tsx, src-tauri/crates/tauri-plugin-peer/src/role.rs (set_stored_role), peer_set_local_role The one decision this reader makes about the sync protocol, and the Devices band is where they make it: a shelf keeps every book's bytes, a satchel keeps the rows and fetches bytes on demand. ⚠️ NO ROW UNTIL 2026-09-12. The row above calls the Devices band "the machines it talks to" and the Sync row treats the role as a fact of the protocol; neither describes it as a control a person operates, which is what it is. Written durably — temp file, sync_all, rename — because a half-written role read at the next launch is a device that silently changed sides, and since 2026-09-12 the temp file is named per role, because a flat role.tmp is a name two concurrent writes share. ⚠️ A RELEASE BUILD HAS NO DEBUG OVERRIDE, so a satchel that receives a build and nothing else resolves to Role::Shelf and answers as the wrong one
Let this device make changes — the per-peer grants Partial src/capabilities/peer/ui/DevicesPane.tsx, src/kernel/core/services/device.ts (device.grant), core/serviceTable.ts (SERVICE_GRANTS) What each paired device may ask this shelf for, set per device from the Devices band and enforced by the envelope router before any handler runs. ⚠️ NO ROW UNTIL 2026-09-12 — it was covered only as one of the thirty-one operations counted two rows down, never as the switch itself. Three refusals the verb makes: a caller naming itself, a list containing device:manage or device:*, and — since 2026-09-12 — a grant whose NAME is not real for a family this kernel owns. Before that last one, book:reed passed the grammar and was stored as a permission the reader believed they had granted and that matched nothing. Partial because the NAME check is scoped to GRANT_FAMILIES: a capability's own circle:read or sync:pull is admitted on the grammar alone, since the kernel does not know a capability's vocabulary and must not refuse it
Sync Shipped src/capabilities/peer, src/capabilities/sync, src-tauri/crates/tauri-plugin-peer Desktop shelf ⇄ satchel: QR pairing with SAS, journal-cursor push/pull, verified blob download with resume, cover LRU. ✅ THE TWO-MACHINE CONVERGENCE RAN GREEN ON 2026-09-06 — the first clean pass in the project's life, and the whole of why this row is no longer Partial. 22 steps, 0 failures, 0 skipped, between this Mac (shelf, 1 962 books) and the second Mac (satchel, 1 962 books), both on 0.2.2. Proved in BOTH directions: a book added on the shelf reached the satchel; a highlight made on the SATCHEL reached the shelf; a tag, its rename and a removal all crossed; both journals then sat unchanged over ten quiet seconds. Transcript: dev-docs/plans/evidence/wi-11-7-20260906T115514Z.md, which is the artefact phase 24's acceptance gate asked for rather than a summary of one. ⚠️ WHAT MADE IT POSSIBLE WAS NOT NEW SYNC CODE. Both halves shipped in 0.2.2 hours earlier and were watched working during the run: the retry ladder turned {"kind":"unreachable","message":"connect failed: timed out"} into a sync.session-ok pulling 162 rows 1.8 seconds later, where before 0.2.2 that refusal was terminal; and sync.session-ok is what made "a session happened" observable at all. ⚠️ AND THE RUN PRODUCED A FINDING NO EARLIER RUN COULD REACH: an app launched while its Mac's screen is LOCKED does not resume when the screen is unlocked. Measured — no timer of any kind for two minutes after unlock, its 30 s round 6½ minutes overdue, recovering within fifteen seconds of one set frontmost. That is a DIFFERENT state from an unfocused window, which this harness had already measured as merely throttled to ~2 s and which it explicitly refutes as stopping outright; the two do not conflict. app_start raises the window now, because the harness was creating the condition itself on every restart. How it got here, all now past tense: phase 8 half-ran it (WI-8.6); phase 11 built the ssh harness; three runs unblocked three real defects — an ephemeral iroh port, Surge's TUN making the LAN look symmetric-NAT, and a release build demoting the satchel because PAPER_ROLE is compiled out of release; a fourth on 2026-09-05 got the preflight green for the first time and then failed 5 of 21 convergence steps. That failure was read as a wedged satchel and was not one — the satchel had no clock, so it synced once per launch, and a not-ready startup race was labelled retryable with nothing on the client side to retry it. Two of that run's three headline observations measured nothing: silence after sync.started is what SUCCESS looked like before sync.session-ok existed, and nextSeq 10306 against 24066 compares two journals that each allocate seq under their own epoch. The comparable number is sync.cursor, whose since is a seq in the shelf's space; the harness reads and reports it now. ⚠️ WHAT IS STILL NOT PROVED, so this Shipped is not read wider than it is: this is DESKTOP shelf ⇄ DESKTOP satchel. Mobile composes [peer, sync] and syncs rows, but a phone still cannot open a book; handover between two desktops is designed and unbuilt (dev-docs/handover.md); and the circle's own two-machine run, which HAS now happened (2026-09-07, one passage, one direction), is not a convergence run and proves nothing about this row. There is no local-only toggle any more — it was stored, drawn and read by nothing, and was removed rather than left as a promise (src/capabilities/peer/ui/devicesModel.ts). Since 2026-08-28: a stale record from a satchel cannot resurrect a book the shelf removed (WI-20.1); one refused book no longer wedges a satchel's session, an invalid marks answer is quarantined and re-asked rather than failing the pull forever, and every refusal reaches the reader as its own sentence naming the shelf by its pairing name — not "your Mac" (WI-20.25). Runbook: dev-docs/sync.md
A published service table Shipped core/serviceTable.ts, core/services/, src/kernel/index.ts Thirty-one <noun>.<verb> operations, each with a grant the router checks before the handler sees a byte. Was twenty-eight on 2026-08-23 — the three since are book.position, content.read and cover.read, all of them the browser client's, and book.set grew the reader's opinion of a book (status, rating, review) without adding a row. A capability may contribute its own services beside the table's, and they are not in this count: circle adds five, gated on circle:read, which only a circle pairing grants. ONE declaration produces the router registration, the client stubs, the CLI command and its --help; a row with no handler is a compile error. ⚠️ IT DOES NOT PRODUCE dev-docs/service-table.md, AND THIS ROW LISTED IT AMONG THE OUTPUTS. That document is HAND-KEPT; the generator that once wrote it was deleted in a1f256f. ⚠️ AND "nothing holds that document to the declaration" IS HALF WRONG SINCE 2026-09-10. pnpm ledger:check reads the table's <noun>.<verb> names and reports any a document never mentions, so a renamed row is a finding — verified by doing it. It holds the NAMES and nothing else: a row whose fields, grant or --help text has drifted is exactly as invisible as before, which is the drift the 2026-08-27 audit found and the 2026-09-12 pass found again in seven places
paper, the command line Partial src/cli/, src/hosts/node/, scripts/build-cli.mjs pnpm build:cli, then paper <noun> <verb> [--json] against the app's own data directory, hosting KernelServices in-process — no daemon. The binary is a gitignored bundle, so this row names the source rather than the artifact: a Where claim on a build output is red on every fresh clone. Partial — a writing command takes an advisory lock, and since 2026-08-28 the app takes the SAME lock at launch (WI-20.40), so paper book add beside a running app is refused by name, with the app's pid, on every platform. The pgrep is gone (WI-20.34, landed): the lock IS the answer to "is the app running", where the guess answered unknown off macOS and refused every write, and could not see pnpm app here. A READ holds no lock and therefore may not write — loadShelf's rescan is served from memory rather than written back through the same temp filename the app's own index uses. book set no longer offers --title/--author — an edit the kernel could not keep is refused by name (WI-20.7). --shelf HAS ITS TRANSPORT since 2026-08-30 (src/cli/remote.ts, nodeSocket.ts, shelfAddress.ts), and this row said it had none: core/shelfChannel.ts is the same envelope to the same router over a WebSocket, and the CLI supplies a Node socket carrying the session cookie on the handshake. It stays Partial for a reason that is now the transport's rather than its absence — the shelf grants a web session reads plus book.position and nothing else, so a WRITE over --shelf is refused by the shelf with the envelope's own forbidden. The credential comes from PAPER_CLIENT_COOKIE (which requires PAPER_CLIENT_ORIGIN naming the same shelf) or is minted from PAPER_CLIENT_CODE; there is no --cookie flag, because a flag puts a credential in shell history and in ps. An EXACT origin, and plain http: only to loopback. Runbook: dev-docs/cli.md

Part 2 — The comparators

Code Reader Why it is here
Rst Readest Closest peer: foliate-js + Tauri 2, same architecture
Fol Foliate Upstream of the engine Paper renders with
Tho Thorium Readium reference implementation; the accessibility benchmark
KOR KOReader The power-reader maximum
Cal Calibre viewer The library-first incumbent
ABk Apple Books Platform default Paper is judged against on macOS
Kdl Kindle The mass-market baseline
Rdw Readwise Reader Where reading-to-notes competes
Zot Zotero Annotation as scholarship
Hgl Highlights Annotation-to-Markdown on macOS

● has it ◐ partial ○ lacks it ◍ stub – not applicable


Part 3 — The gap matrix

Table stakes Paper is missing

Capability Paper Rst Fol Tho KOR Cal ABk Kdl Rdw Zot Hgl
Reading statistics ○ ● ● ○ ● ◐ ● ● ● ○ ○
Translation ○ ● ● ○ ● ○ ● ● ● ○ ○

⚠️ AND OF THE TWO LEFT, ONE IS A POSITION. Translation is declared in Part 4 as deliberate absence — a provider, a network dependency and a bill, for something a reader can do in another window. So reading statistics is the last genuine table-stakes gap Paper has, and it is stated here rather than left for a reader to work out by subtracting. Nothing records a session; the missing piece is a session store, which is a phase and not a work item.

Cross-device sync left this table on 2026-09-10, and it was three days late. It carried ◐ while Part 1's Data section had said Sync | Shipped since the two-machine convergence ran green on 2026-09-06 — 22 steps, 0 failures, both directions, transcript in dev-docs/plans/evidence/. Removed on the 2026-08-23 rule below rather than flipped to ●, because a row where Paper is filled is not a row Paper is missing.

Closed since the last audit: reading position restored, reopenable library, multiple highlight colours and dictionary lookup were all rows in this table and are now shipped. Bookmarks left it earlier.

Closed 2026-08-21, removed from this table 2026-08-23: annotation export, the footnote popover and back-after-a-jump all shipped the day this matrix was built, and were left in it carrying a ● — a row where Paper is filled is not a row Paper is missing, and a heading that does not describe its own rows is how a reader stops trusting the rest of them. Their capability rows are in Part 1.

Contested — real features, but not universal

Capability Paper Rst Fol Tho KOR Cal ABk Kdl Rdw Zot Hgl
Text to speech ● ● ● ● ● ● ● ● ● ○ ○
Full-text search in book ● ● ● ● ● ● ● ● ● ● ●
Library search — rows ● ◐ ○ ○ ● ● ◐ ● ● ● ◐
Library search — contents ○ ○ ○ ○ ● ● ○ ● ● ● ◐
Continuous scrolling ○ ● ● ● ● ● ● ● ● ● ●
Touch page turn ○ ● ● ● ● ● ● ● ● ● ●
Highlight styles beyond fill ◐ ● ● ● ● ● ◐ ◐ ◐ ● ●
OPDS / catalogs ○ ● ● ● ● ● ○ ○ ○ ○ ○
Collections / tags ● ● ◐ ○ ● ● ● ● ● ● ●
Auto-scroll ○ ● ○ ○ ● ○ ○ ○ ○ ○ ○
Parallel / split reading ○ ● ○ ○ ○ ○ ○ ○ ○ ○ ○
Per-book settings ○ ● ● ○ ● ● ○ ○ ○ ○ ○
Spaced repetition ○ ○ ○ ○ ◐ ○ ○ ○ ● ○ ○
AI assistance over the text ● ◐ ○ ○ ○ ○ ○ ◐ ● ○ ○
PDF and EPUB in one engine ● ● ● ● ● ◐ ◐ ○ ● ● ◐
Accessibility certification ○ ○ ◐ ● ◐ ◐ ● ● ◐ ◐ ◐

AI assistance moved from ◍ to ● on 2026-08-23. It was a stub — a provider interface and a refusal — and phase 15 built the thing behind it. Worth saying what kind of ● it is, because the column does not carry it: Paper's runs LOCALLY by default, and the answer's citations are resolved by Paper against passages it numbered rather than trusted from the model. Of the two others with a mark here, one is a cloud service. Provenance mark for machine-written text below stays ◍ deliberately: the kind, the tint and the reserved style exist and nothing produces one yet.

⚠️ AND THAT JUSTIFICATION NOW ARGUES FROM A SURFACE NO READER CAN OPEN, NOTED 2026-09-11. "The answer's citations are resolved by Paper against passages it numbered" is the COMPANION, and the Companion panel went behind ⌘⌃⌥D on 2026-08-30 — seven days after this ● was placed, and the sentence was never re-read. The cell itself survives on a different foot: the gloss is AI over the text, it runs locally, and it is offered to every reader as Look up on the selection bar. So ● is right and its stated reason is not what a reader would meet. Kept rather than rewritten to ◐, because the mark is about what the app does and the paragraph is about why — and this is the second note in Part 3 to have gone on arguing from a state that moved under it.

What Paper has that most do not

Capability Paper Rst Fol Tho KOR Cal ABk Kdl Rdw Zot Hgl
Reading ruler with keyboard line advance ● ○ ○ ○ ○ ○ ○ ○ ○ ○ ○
Margin notes beside their own line ● ○ ○ ○ ○ ○ ○ ○ ○ ◐ ◐
Enforced line grid, per-size measure ● ◐ ◐ ◐ ● ◐ ○ ○ ○ ○ ○
Size corrected for the face's x-height ● ○ ○ ○ ○ ○ ○ ○ ○ ○ ○
Word-boundary snapping with gesture provenance ● ○ ○ ○ ◐ ○ ◐ ◐ ○ ◐ ◐
Alignment and hyphenation as one 3-state decision ● ○ ○ ○ ○ ○ ○ ○ ○ ○ ○
Cards typed apart from notes ● ○ ○ ○ ○ ○ ○ ○ ◐ ◐ ○
Provenance mark for machine-written text ◍ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○
Spoken word followed on the page ● ◐ ◐ ● ○ ◐ ● ● ○ ○ ○
Passages from named readers, drawn in your own copy, peer to peer ◐ ○ ○ ○ ○ ○ ○ ◐ ○ ◐ ○

The circle's row, added 2026-09-05, and what its cells do and do not say.

~~Paper's own cell is ◐ and not ● for one reason: the two-machine run has never happened. The code is complete and the design is answered; nobody has watched a passage cross.~~

⚠️ STRUCK 2026-09-11: EVERY CLAUSE OF THAT WAS VOID, AND HAD BEEN FOR FOUR DAYS. The passage crossed on 2026-09-07, both directions were proved on 09-08, and Part 4's own chain says so twice. Part 1's row was corrected the same week; this note was not, because the 09-10 pass declared outright that "Parts 2, 3 and 4 were not revisited" — and a paragraph nobody revisits is a paragraph that keeps arguing from a state the rows above it have left. That is the third time this document has recorded the identical failure, and it is always Part 3 or Part 4 rather than Part 1: a row is edited when its feature moves, and an ARGUMENT about the rows is edited only when somebody re-reads it.

The cell stays ◐, and the honest reason is narrower. Not that nothing has crossed — it has, repeatably, under scripts/circle-scenario.sh — but that the far Mac holds the bytes of three books out of 1 961, so what has been watched end to end is a passage in a book both machines could open. That is the same constraint WI-24.C3 ran under and the one Part 1's row now names.

⚠️ The other ten cells in that row are the softest claims in this document. Parts 2 and 3 are 2026-08-21 research and were not re-done here; these were placed from general knowledge, and two of them are genuinely arguable rather than merely unchecked:

Nobody in this table does the actual thing — named people, your own copy, no server, no account. Whether that is a moat or a niche is not a question a matrix answers.


Part 4 — Reading the matrix

The gap that dominated this document is closed, and its explanation is obsolete. Earlier versions said reading position was Partial because a dialog-selected path was in the filesystem scope for that runtime only, so a reopen worked until you quit — and that the fix rested on tauri-plugin-persisted-scope, a dependency's behaviour never run here.

Phase 4 dissolved the problem rather than fixing it. A book is a folder: the bytes are copied into $APPDATA/books/<bookId>/content.<ext> at import, and useBookIntake.ts:249 opens that, through contentPathIn. The picked path is still kept, but it is device-local metadata and not what an open reads. A scope that expires cannot break a reopen, because no reopen consults it.

Position restore and reopening are both Shipped. The lesson worth keeping is not about scopes: it is that a row can stay Partial for a year after the reason expired, because nobody re-reads a ledger while shipping five phases.

~~Nothing gets out — except tags.~~ Closed, and kept as a worked example of how this section rots. The paragraph here said marks and cards had no door and called the asymmetry "the sharpest thing in the document". marksArchive.ts shipped one — JSON to re-import and Markdown to read, rendered from the same document so the two cannot drift — and the Annotation section has said so since 2026-08-21. A Part 4 paragraph is an ARGUMENT ABOUT the rows and nothing checks it against them, which is why it survived four passes over the rows above it.

~~The new first-ranked gap has no comparator that lacks it.~~ Also closed. This said there was no way back after a jump. core/jumpStack.ts shipped ⌘[ and ⌘], cross-book, and the footnote popover it said the gap compounded with shipped in the same phase. Both rows above have said so since 2026-08-21; this paragraph did not.

~~The first-ranked gap now is that nothing has been watched working across two machines.~~ Both halves are now closed, five weeks apart in the writing and two days apart in fact. Sync's convergence ran green on 2026-09-06; the circle's first passage crossed on 2026-09-07. Neither was blocked by missing code and both were blocked by an absent harness run, which is what this paragraph got right.

~~The first-ranked gap now is that the circle has no harness.~~ Closed the same day, 2026-09-07. scripts/circle-scenario.sh runs the crossing unattended — preflight, publish through the app's own Share control, watch the pub land on the far end, hold the person back and confirm the record, release them, put the publication back — and --falsify proves the converge step can fail. WI-24.C2.

~~The first-ranked gap now is that the far end has never published.~~ Closed 2026-09-08. the second Mac published and the passage arrived here — pub ff49a654873eac — so the circle is proved in BOTH directions, and the mute assertion's "file stays" half is a real assertion rather than a note. The far end is driven by building the debug BUNDLE here, copying it, and tunnelling its bridge back with ssh -L; that machine has no Rust toolchain.

~~The first-ranked gap now is WI-24.C3~~ — shelf disclosure and a jacket verified against its digest. Closed 2026-09-08, and this chain did not say so until 2026-09-10. dev-docs/plans/phase-24-the-evidence.md records the run: the per-person switch turned on from the Circle screen, circle/<us>/shelf.json landing on the far end with 1 962 works, and a jacket that is VERIFIED rather than merely present — 62 579 bytes whose blake3 is exactly the name it is filed under, which a build that kept whatever arrived would also have left a file for. The constraint this entry recorded is real and survives: the far end holds three books' bytes out of 1 961, because it came from a satchel sync and has records without content, so a book with no content.epub cannot be opened there and digest verification reaches only those three.

The first-ranked gap now is that the public layer has never left one process. Phases 25 and 26 landed twenty work items on 2026-09-09 — two ALPNs, a second signing key, a global provider index — and every one of them records in-process tests as its whole verification. For a transport that means two live iroh endpoints inside share/acceptance.rs, which is real evidence about the protocol and none at all about the app. There is no public-scenario.sh.

⚠️ AND THIS IS THE THIRD TIME THIS CHAIN HAS NAMED A GAP OF EXACTLY THIS SHAPE. Sync's convergence and the circle's first crossing were both blocked by an absent harness run rather than by absent code, and both took one run to close and produced a real defect apiece when they did — a satchel that never retried, and a 'muted' state modelled for four phases with no writer anywhere. sync-scenario.sh and circle-scenario.sh are the pattern, and the far Mac's three-books-with-bytes limit constrains this run exactly as it constrained WI-24.C3.

⚠️ AND WHAT BUILDING THE HARNESS FOUND IS THE ARGUMENT FOR HAVING BUILT IT. 'muted' had been modelled since relationships were designed — parsed, admitted by acceptsTransport, given retain: 'keep' on its own stated reasoning — and nothing in the product ever wrote it, so the only way to take one person off the page was Remove, which purges their passages and the pairing. No test could see it, because every test built the state it wanted directly. Trying to RUN the thing is what asked.

What is genuinely Paper's, and it is more than it was. The ruler with a keyboard line advance, margin notes beside their line, a typographic grid where every size carries its own measure — and three things this audit surfaced that had no row at all: type sizes corrected for each face's measured x-height, word-boundary snapping that knows whether the pointer or the keyboard made the selection, and alignment and hyphenation collapsed into one three-state decision with the rivers measured rather than argued.

The card model — five named kinds, deliberately not notes — is closer to a Zettelkasten tool than to a reader, and no reader here has it.

The companion is built, and the provenance mark is still the original idea in it. The companion kind, its amber tint, and a wave style reserved so the reader cannot make a mark that looks machine-written — that was the part that shipped first, and phase 15 filled in the thing that produces one: a supervised local runtime, a grounded thread whose citations Paper resolves rather than the model inventing, and a gloss that defines a word in the sentence it sits in.

Both of the limits this paragraph used to state have moved. The agent routes' terms review cleared on 2026-08-23 and they ship. Read-aloud is not "deliberately absent" without qualification either — the SYSTEM voice is shipped, follows the spoken word across page turns and section boundaries, and speaks in the language the document declares; it is the NEURAL voice that is absent, because Test voice proves a model works and pretending otherwise would ship a model nothing uses. Two rows called "Read aloud" with opposite states is how a reader stops trusting a table, which is why they carry their voices in their names.

And the circle is the largest thing Paper has that this section had never argued about. It is the one feature here that changes what reading is in the product rather than how it looks: passages a named person marked, drawn in your own copy, with no server, no account and no aggregate. The three rules it holds itself to are all refusals — no aggregate is ever presented as the aggregate, nothing is discoverable that you were not given, nothing is multi-writer — and every one of them is a thing the obvious version of this feature does. The brief was "a peer-to-peer Douban" and the design's first section is why it cannot be one.

Where Paper should not chase. OPDS, catalogs, per-book settings and parallel reading pull against a reader whose whole argument is one uncluttered surface. Translation belongs on that list too: it needs a provider, a network dependency and a bill, for something a reader can do in another window. Absence there is a position, not a gap — provided it is stated, which is what this ledger is for.

Which leaves exactly one table-stakes gap, and it is reading statistics. Cross-device sync left that table on 2026-09-10 having been shipped since 09-06, and translation on the line above is a position rather than an absence. So of the three rows "Table stakes Paper is missing" was built with, one shipped and one was reclassified, and statistics is the whole of what is left. Say it here rather than making a reader subtract: nothing records a session, and a session store is a phase.

Touch stopped being a future problem and became a present one. This said "phase 9 puts Paper on a phone" as though the phone were ahead. It is here: iOS and Android both build and run their own shell, and the browser client already turns pages by tap on a device with no wheel. What is missing is narrower and sharper than the paragraph it replaces — the phone has no reader. The shell is a shelf, a card deck and a settings screen; mounting the reading core into the Reading tab is the one change that turns four tabs into an app, and long-press selection and pinch are the vocabulary that arrives with it.


The 2026-08-21 re-audit

Eleven rows had drifted. Nine of them understated the app — a ledger that reports features as missing when they ship is worse than one that is merely out of date, because it is read to decide what to build next.

Row Was Is Why it drifted
Justification, hyphenation Partial, "no control" Shipped, three-state Commit 62f54ef, four commits before this audit
Dictionary lookup Absent Shipped (macOS) ui/lookUp.ts shipped and wired; row never revisited. Superseded again since: the macOS hand-off is deleted and the row is Look up, which is now the gloss alone
Multiple highlight colours Absent Shipped, three tints MARK_TINTS and the selection bar's picker; row never revisited
Underline / ink Absent Partial — fill + underline Same change; wave exists but is reserved for the companion
Library shelf Partial, "no search, no removal, no tags, colour covers" Shipped Phases 3–5 built all four; data/fixtures.ts is deleted
Reading position restore Partial Shipped Phase 4 made the vault the open path
Reopening a picked file Partial Shipped Same cause
Data import / export Absent Partial — tags ship tagArchive.ts
Folder watching Stub Absent The false claim was removed from the UI
Keyboard map 8 combos 13 ⌘L, ⌘B, ⌘T, PageUp/PageDown, ⇧Space
Every Where path lib/… kernel/core/…, kernel/ui/… The kernel carve

Rows added that had never been recorded: word-boundary snapping and gesture provenance, the four spacing axes, brightness and contrast, x-height-corrected sizes, real cover art, the virtualised shelf, background enrichment, yielding passes, copy with citation, overlap-based mark matching, unsaved-note rescue on close, cross-document coordinates, tint derivation, alignment respecting composition, drag-to-tag, trash and undo, the presence register, relative dates, floating-surface placement, two-click removal, identity migration, and the reserved companion style.

Why this happens, and the only fix that would work. Paper's own convention is that a document in dev-docs/ is the single source of truth and must not be restated elsewhere — but this ledger restates the code, and nothing checks it. The state column and the Where column are both mechanically checkable: a path either exists or it does not. Wiring that into pnpm verify, as check-compositions already does for the capability manifest, would catch the path drift immediately and make the state drift visible at review.


Sourcing

Paper's column describes the working tree as re-audited on 2026-09-05, at 0.2.0 on main, row by row against the source. (It said 2026-08-21 on branch bookmarks until then — three passes had moved rows above and left this sentence naming a branch that is four releases behind.)

Verification of Paper's column is source reading plus the test suite, not a pass in the running app. That distinction matters here more than usual: this project has twice shipped a defect that every automated check passed over — the wrong filesystem permissions, and a runtime-only path scope — and both were the kind only the app can find. Rows marked Shipped mean implemented and tested; they do not all mean watched working.

Not driven in the app since they were written:

Row What has NOT been done
Book content cannot reach native the hostile EPUB has never been opened; the CSP has never rendered a real book
~~MOBI / AZW3 / CBZ / FB2~~ CLOSED 2026-09-05. Five formats (the row missed .fbz), twenty cases, and a capture of each painting in the app. See the row in Part 1
RTL, vertical writing never tested; the row says Unknown for that reason
Gestures a trusted wheel event cannot be synthesised, by design, so the bridge cannot stand in for fingers
Sync RUN 2026-09-05, and it is no longer "half-run": the preflight passed in full and the convergence failed 5 of 21 steps, with the satchel wedged at nextSeq 10306 against the shelf's 24066. A named failure with transcripts, where this row previously recorded an absence. See the Sync row in Part 1
The circle, every row of it no two-machine run of any kind. There is no circle-scenario.sh, and sync-scenario.sh does not mention the circle. A passage has never been watched crossing between two machines; the evidence is unit tests and index.peer.test.ts against a peer fake
The phone it launches on both platforms and was captured doing it — dev-docs/artifacts/ios-library.png, ios-you.png, android-library.png. That is three captures of two tabs; "every tab renders" is held by MobileApp.render.test.tsx, not by a picture, and the tab those captures call "You" is now Settings. Nothing has been read on it, because there is no reader to read in
The browser client driven by scripts/shot-client.mjs, which is more than most rows here have. The grant boundary is held by tests rather than by an attempt to exceed it from a real browser

Comparator columns for Readest, Foliate, Thorium, KOReader, Readwise Reader, Zotero and Highlights were checked against current sources in August 2026 — see the links below, and note they were not re-verified in the 2026-08-21 pass, which was Paper's column only. Apple Books, Kindle and the Calibre viewer are from general knowledge of stable, long-standing products; treat those three columns as the softest in the table.


The 2026-08-23 re-audit

Two days, two phases, and a ledger that had not moved with either. Phase 13/14 landed the CSS system on 2026-08-22; phase 11's service table merged on 2026-08-23. Walked Part 1 against the working tree again.

Two rows contradicted this document's own other rows, which is the failure that matters most in a file read to settle questions:

Seven rows were missing, all for shipped work. Five are the CSS system, which is the largest single change since the last audit and had no row at all: the sheet is two static tiers now rather than 585 lines rebuilt as a string on every setting change; a setting is a property write; the base size moved to the root, where rem can follow it; non-prose has house ratios; code has a panel mixed from the reader's own ink. Two are phase 11's: ⌘Q now closes the sync journal through a real handshake, and the bundled OFL typefaces' licences travel with the build.

That last one is worth naming on its own. Four typefaces shipped in every copy of Paper under a licence that permits redistribution only with the notice and the licence text attached, and neither travelled. No test failed, no build broke, and the app worked perfectly — which is the whole shape of the defect.

Three line-numbered claims are gone (core/uiTypes.ts:150, ui/screens/BookRow.tsx:193, ui/hooks/useBookIntake.ts:249). One had already drifted by a line and one pointed at unrelated code. A line number in a document expires on the next edit to the file, and features:check could not catch it because the file still exists. Same rule now applied in both ledgers. (That gate has since been removed altogether — see the warning at the top.)

One row was checked and stayed Partial. Cross-device sync: phase 11 built the harness that drives two Macs over ssh and three runs of it unblocked three real defects, and the convergence itself finally ran green on 2026-09-06.

What was NOT re-audited: Parts 2 and 3 — the eleven comparators and the gap matrix — were researched on 2026-08-21 and have not been re-researched. This pass moved three rows out of the "missing" table because Paper had filled them, which is a claim about Paper; it did not re-check a single claim about anybody else's reader.


After phase 15

The companion runtime updated the Companion section and Part 4 as it landed, which is the practice — a phase that revises the ledger with its own work is the only version of this that scales. What it could not do is notice the three things a phase never sees about itself.

IT COLLIDED WITH A NAME. Read aloud already existed under Reading aids, Shipped, over Web Speech. Phase 15 added a second Read aloud, Absent, over the neural voice. One document, one name, two rows, opposite states — the same defect the pane count had, and the reason it is worth naming twice is that both arose the same way: a section edited by someone reading only that section. They are Read aloud, system voice and Read aloud, neural voice now, and each says the other exists.

IT MADE A ROW ELSEWHERE STALE. Dictionary lookup said Shipped (macOS) and that a platform without a dictionary shows no control. The gloss changed both halves: Look up became a three-mode control, and a Windows or Linux reader with a text model got one for the first time.

⚠️ AND THAT PARAGRAPH IS ITSELF STALE — kept as the worked example, because this is the second time this one row has gone quietly wrong. The system-dictionary hand-off is deleted: Look up has ONE mode, the gloss, and the no-regression rule it used to carry is deliberately broken on macOS, where a fresh install now has no lookup until a model is downloaded. Nothing went red either time, and there is now even less that could: features:check read the Where column and never read prose, and it no longer runs at all. The Look up row states the current behaviour and the regression together; this note exists to say the row has been wrong twice, for the same mechanical reason, and will go wrong a third time unless it is rewritten in the commit that changes the behaviour.

IT LEFT A MATRIX CELL BEHIND. AI assistance over the text was ◍, a stub, and Part 1 said Shipped in four rows. Nobody owns a comparator cell, which is how it stayed. It is ● with a note on what kind.

One row added: the work line on the shelf (WI-15.12). The library status bar's third rung had no row at all, and its default — NO_WORK_LINE — is what keeps a build without inference drawing the bar it always drew.

NOT re-audited: Parts 2 and 3 remain the 2026-08-21 research. One cell moved here, and that cell is a claim about Paper; nothing in this pass re-checked a claim about another reader.

Not in this document, deliberately: Settings → Local models threw CapabilityError on open the day this merged, because the look-up mode was a kernel setting and a capability's settings handle is confined to its own namespace. That is a defect and its fix is a commit, not a row — the ledger describes what Paper has, and a row per bug would make it a changelog. It is mentioned here only because the Look up row's wording depended on the seam that fixed it — a dependency that has since gone, along with the mode, the setting and the accessors the seam was built to reach them through.


The 2026-09-05 re-audit

Twelve days since the last full pass, three phases (21, 22, 23), three releases (0.1.2, 0.1.3, 0.2.0), and a failure mode this document had not seen before.

THE DRIFT WAS NOT IN THE ROWS. IT WAS THAT THERE WERE NO ROWS. Every previous pass found rows that had gone stale — a Partial that had shipped, a path that moved with the kernel carve. This one found three whole surfaces with no entry of any kind: the circle, the phone and the browser client. Two of those had been shipping for a week and one for two.

Surface Landed Rows here before
The browser client (phase 18) 2026-08-24 onward none
The phone's own shell 2026-08-30 none
The circle 2026-09-01 → 09-04 none

Thirty-one rows added, twenty-one of them the circle's.

Why a whole section can go missing when a row cannot. The convention this document runs on is that a phase updates the ledger with its own work — the practice "After phase 15" named as the only version that scales. It scales for a phase that CHANGES something described here. It fails silently for a phase that adds a surface nobody has written a heading for: there is no stale row to notice, no path to go red, and the table of contents reads exactly the same as it did. A missing row looks like a document that is finished.

gen-feature-ledger.py cannot help — it renders what is here and validates the state words. It would have refused a typo in a State cell and had nothing to say about three absent sections. This is the same shape as features:check and check-service-docs, one step worse: those gates checked that what is written is true, and nothing at all checks that what is true is written.

The seven rows that had gone stale

Row Was Is Why it drifted
Annotation export "a name-matched book's marks are NOT imported … a user-visible regression" They are imported, unplaced, and re-anchored on open WI-21.7 and WI-22.A2 reversed it; the row also named an unimportable list that was renamed unplacedBooks
Re-anchorable annotations Stub — "nothing re-anchors with them yet" Shipped reanchorPass.ts, and it is a store write
Work identity Stub — "nothing reads it" Shipped And the bigger half of the error was that dc:identifier was never STORED, having been parsed for years
A published service table 28 operations 31 book.position, content.read, cover.read — all the browser client's
paper, the command line "--shelf has no Node-side transport" It has one Landed 2026-08-30; the pgrep the row also described is gone
Side pane, 8 panels 8, full stop 8 is the KERNEL's own count Capabilities contribute panes now, under <capability>:<name>
Sync "Mobile is phase 9" Mobile composes [peer, sync] and syncs rows It cannot open a book, which is a different sentence

One of those is worth naming on its own. The Annotation export row carried a paragraph in bold announcing a user-visible regression and asking for the trade to be "re-argued rather than inherited" if a gated spike did not happen. The spike happened, the capability came back, and the warning sat there for five days telling readers of this document that marks are discarded. A ledger that announces a regression which has since been fixed is worse than one that is merely behind — it is read to decide what to build next, and this one was advertising work that was already done.

Part 4 was arguing from rows that had moved

Four paragraphs, and the two loudest were the two most out of date: "nothing gets out — except tags", called "the sharpest thing in the document", when marks and cards export had shipped on 2026-08-21; and "the new first-ranked gap has no comparator that lacks it", about a jump stack that shipped the same day.

Both are kept, struck through, with what closed them — the same treatment the Look up note gets, and for the same reason. Part 4 is an ARGUMENT ABOUT the rows, so nothing in a row's own update touches it, and it will rot again unless it is read as a section rather than skipped as prose. It is now the last thing to check in a pass, not the first thing to skip.

What was NOT audited

Parts 2 and 3 are still the 2026-08-21 research, for the fourth pass running. One row was added to Part 3 — the circle's — and its ten comparator cells are the softest claims in this document, placed from general knowledge and flagged as such where they sit. Paper's own cell there is ◐ rather than ● on purpose.

Nothing was driven in the running app in this pass. Every state above is source reading plus the suite, which is what "Shipped" has meant here since the Sourcing note was written — and the gap it warns about is wider now than it has ever been, because the two largest additions since the last pass are a phone with no reader and a peer protocol nobody has run between two peers.


The 2026-09-06 pass

Narrow by design. One day, five commits, 0.2.0 → 0.2.1, and no new surface — so this was not a sweep. The commits: a renderer fix for this document's own drift table (5fb3920), the format corpus (411cb0e, already in its row before the merge), the MCP bridge ACL (2022ef7), messageOf moved into the kernel (5f02ef4), a jacket fixture shrunk from 512 KiB to 2 KiB (d039d7c), and vitest 4 (62688a5). Four of the six change no capability a reader can reach.

What this pass did instead was read the code behind a row it had just written. The Sync row was given the Stage B transcript on 2026-09-05, hours after the run. It was a faithful transcription — and it inherited the harness's inferences along with its measurements, which is a failure mode this document has not recorded before.

The row said What the source says
"logging sync.started and then no session event of any kind", offered as evidence of a wedge lib/ledger.ts makes no diagnostics call anywhere, and run() logs only on failure. Silence after sync.started is what success looks like
"the satchel sat at nextSeq 10306 against the shelf's 24066", read as ~13 700 behind Each journal allocates seq: nextSeq++ under its own epoch. Two independent counters. The comparison has no arithmetic meaning
"the open question is what triggers a satchel's sync session" lib/scheduler.ts's own header answers it in four lines: start, visibility, local commit (5 s debounce), syncNow(). No clock. A satchel nobody touches syncs once per launch
"the one session ever logged said not-ready … marked retryable: true" Nothing reads that flag. refusalOf returns null for a retryable refusal, so the session throws and no trigger fires again. Terminal, not transient

⚠️ A TRANSCRIPT IS EVIDENCE; THE SENTENCES AROUND IT ARE AN ARGUMENT, AND ONLY THE FIRST SURVIVES BEING COPIED INTO A ROW. Every number above is real and correctly recorded. What travelled with them and should not have is the reading — wedged, behind, stopped. This is the same shape as Part 4 arguing from rows that had moved, one level down: a row arguing from a run whose instrument could not see the thing it was being asked about.

The check that would have caught it costs nothing and was never run: open the module whose behaviour the row is about, before writing the sentence that explains the measurement. Four source files, twenty minutes, no second machine.

What this does not close

The Sync row stayed Partial through this pass, because nothing in it had been observed in a running pair — it was source reading, which is what "Shipped" has meant here since the Sourcing note.

✅ AND THEN IT DID NOT STAY PARTIAL. Later the same day the convergence ran green, 22 steps and 0 failures, and the row is Shipped. Worth keeping the two notes next to each other: the source reading named the mechanism and the run confirmed it, and the run was possible only because the mechanism had been named first. The hypothesis this section said the next run would have to falsify is the one it went and falsified.

The circle, end to end was not touched. Stage C has not run, and nothing in this pass bears on it.


The 2026-09-09 pass

0.2.1 → 0.3.0. Forty-three commits and one merge: the circle's rounds, an audit of every deferred finding, and three evenings on a pairing that did not work. Most of those commits updated their own rows as they landed, which is the practice this document wants. This pass is for the two things a phase cannot see about itself, and they turned out to be the same thing twice.

Both rows claimed more than had been observed

Row What it said What was true
Admission: two shelves pair Shipped Failed three times in four for any real reader, with twenty green protocol tests over it
The circle, end to end "Both DIRECTIONS are proved" and "the far end has never published" The first. The second predated the 09-08 crossing and was left standing three sentences below its own refutation

The second is the drift these passes keep describing, in its purest form: Part 4 recorded the closure the same day the crossing happened, and the row did not. One cell, both states, and nothing between them but the reader's attention.

"Shipped" cannot see a cold path

The pairing row is the sharpest example this document holds of what the Sourcing note means. Every test was green and stayed green — including two_shelves_complete_a_circle_pairing_and_still_compare_a_sas, written for exactly this capability. The protocol was correct. What was broken was underneath it, and no test in this tree could reach it.

⚠️ AND THE FIRST MEASUREMENT AGREED WITH THE TESTS. A harness dialling every five seconds reported 10–11 of 12 and the number was written down as "about one in six". It was an artefact: five seconds apart keeps the network path warm, and a warm path is reliable. Dialled 60 s apart — which is what pairing actually is, two readers who have never talked, one offer, one dial — 1 of 4 got through.

A measurement of a peer-to-peer capability that loops without an idle gap reports the reassuring number. That belongs beside the 09-06 pass's lesson rather than under it: that one was a row inheriting a harness's inferences, this one is a row inheriting a harness's cadence. Both survive being copied because both look like data.

What the fix was, and what it cost

The shelf sends nothing between receiving a hello and its human answering, so a joiner cannot distinguish you never heard me from your human is still deciding. A lost hello therefore raised no error at all — it left the joiner holding a connection it believed was healthy for 150 seconds. So the joiner now asks: a second connection beside the first after five seconds, whichever is heard wins.

The price is in the ledger's own terms — every successful pairing now opens two connections and the far end refuses one, so peer_status.droppedInbound climbing on the offering side is the design. Removing it needs an ALPN bump, deliberately not taken.

Three instruments, and why they belong in a ledger

None of these is a feature, and the pairing row could not have been written without them:

⚠️ THE THIRD ONE IS THE WARNING WORTH KEEPING. Two of the three were found only because a fourth thing was fixed first. A silent instrument does not report that it is silent, and every explanation built over one is unfalsifiable — which is what three evenings of confident wrong answers about a "dead far end" actually were.

What this does not close

The circle, end to end stays Partial, and for the reason it already gave: WI-24.C3, the shelf and jacket half, has not run. Pairing being reliable is a precondition for that run, not a substitute for it.

Nothing here was re-audited outside the circle. Reading, Annotation, Cards, Companion, Navigation, Shell, Other surfaces and Data were not read against the tree in this pass — the branch touched the circle, the peer plugin and one UI hook, and claiming a sweep would be the same overclaim this pass exists to record.


The 2026-09-10 pass

At 0.3.1. Three commits since the 09-09 pass: two closing the whole of a 215-finding audit of the phase 25/26 branch, and the version bump.

The finding is the same one the 09-05 sweep made, and it is the largest since: two whole surfaces with no row at all. Public sharing and public annotations landed on 2026-09-09 — twenty work items across two plans, two ALPNs, a second signing key, a global provider index, a new capability composed on every platform that has a peer — and this document did not carry one row for any of it. Not a row that drifted. A section that was never written.

Surface Landed Rows here before
Public sharing (phase 25) — a second endpoint, verified transfer, per-request authorization, the DHT 2026-09-09, 9 work items 0
Public annotations (phase 26) — the voice key, the envelope, withdrawal, binding, the bounds 2026-09-09, 7 work items 0

Twenty-one rows added under "The public layer". Two of them are Partial for a reason this section states once and every row inherits: nothing in either phase has crossed two machines or been driven in the running app. Every work item in both plans records in-process tests as its whole verification, and for the transport that means two live iroh endpoints inside one process, which is real evidence about the protocol and none at all about the app.

Why the state is Partial and not Shipped

The circle's own row spent four phases in exactly this position, and the temptation each time is the same: the tests are green, the code is complete, the pieces demonstrably work. The 09-09 pass was written about two rows that gave in to it — Admission: two shelves pair said Shipped over a capability that failed three times in four for a real reader, with twenty green protocol tests over it. Twenty green protocol tests are what this section has.

So "Publishing to strangers, end to end" is Partial with the absence named rather than Shipped with the tests cited, and the section's own preamble says to read it the way the circle's row was read before 2026-09-07.

The two rows that had gone stale

Row What it said What was true
A friend's passage, drawn in the book "ONE neutral hue for every reader, never a palette" True of every FRIEND and no longer true of every reader: the public layer began handing the same painter a stranger's mark on 09-09, and there are two hues. The row had started contradicting a row that did not exist yet
The circle (section preamble) composition.desktop.ts is [peer, sync, inference, companion, circle, webhost] publicSharing joined that list, and the phone compositions, on 09-09. The preamble's conclusion — that every row in THAT section is desktop-only — survives; the list it argued from did not

What the audit changed that a row cannot show

The 215 findings were mostly not about capabilities, so they belong here rather than in Part 1. Three that a reader would notice:

What this does not close

pnpm features:check still does not exist, and this pass is the fourth to be needed because nothing holds these rows to the tree. Twenty-one rows for phases that landed the previous day is what "maintained by hand" costs when a phase does not update its own rows as it lands.

Nothing outside the public layer and the circle was re-audited. Reading, Annotation, Cards, Companion, Navigation, Shell, Other surfaces and Data were not read against the tree — the branch touched the public capability, the peer plugin, the kernel's public and circle cores, and the reader's overlay path. Claiming a sweep would be the overclaim the previous pass exists to record.

Parts 2, 3 and 4 were not revisited. Public sharing is a capability no comparator has, and the gap matrix does not know about it yet — "What Paper has that most do not" is where it would go, and putting it there means re-reading ten comparators rather than adding a line.


The 2026-09-11 pass

At 0.3.2, and it is the first pass to read what the 09-10 pass said it had not. Two commits since: the ledger gate itself (2fb1e83) and the version bump. No capability changed, so nothing here is staleness against new code — every finding below was already false when pnpm ledger:check first ran green over this document. That is the point of the pass: the gate answers three questions and this is what is left over when all three are satisfied.

pnpm ledger:check is green before and after, both times: 195 rows, 320 path claims, 0 findings, 122 declared names all covered. It could not have seen any of this.

What was checked, mechanically, and came back clean

Stated so the findings below are read as the residue of a real sweep rather than as whatever happened to be noticed.

Check Result
Every backticked identifier in every row exists somewhere in the tree 274 distinct, 3 unresolved and all three explained by their own rows (unimportable, deleted and recorded as such; addd117, a commit; _NET_FRAME_EXTENTS, an X11 atom)
Every identifier named in a Where cell appears in the file that cell names clean, once standalone names (peer_share_fetch_notes, the settings keys) are read as their own claims rather than as attached to the neighbouring path
Every Settings → … path against the band map parsed out of Settings.tsx one wrong, below
Every quoted UI string against the source clean — 55 checked, the 20 that did not match are this document quoting its own struck text
Every registry count and range: READING_STEPS, FIGURE_WIDTHS, FIGURE_HEIGHTS, MINIMUM_SIZES, READING_RATIOS, SPACING, ALL_FACES, BUNDLED_FACES, NAMED_PER_GROUP, REFERENCE_X_HEIGHT, SCALE_BOUNDS, MARK_KINDS, SERVICE_TABLE, PANE_SHORTCUTS, THEMES, ACCEPT_FORMATS clean, every one
The three compositions against capabilities.manifest.json clean — the circle preamble's corrected list is right
Every dev-docs/ and docs/ path cited, and every evidence capture all present, including the six format-*.png and the Linux titlebar pair
Every exported function under src/capabilities/ and the circle and public cores, for one with no caller none — the writePublic class the 09-10 audit found has not recurred

Five findings, and four of them are one shape

The shape is a claim that is true of one surface and written about the app. Not drift — none of these was ever right — which is why no pass looking for what changed found them.

# Where What it said What is true
1 Cards, section header "Every row in this section is behind developer options (⌘⌃⌥D)" True of the desktop rail only. Cards is a permanent top-level tab on every phone and every browser client — TabBar's TABS is a literal four and neither root reads paneOffered, UNFINISHED_PANE_IDS or developer at all
2 Companion, section header the same sentence False of five of its ten rows, the gloss among them — and the gloss IS Look up, which has a Shipped row in Reading aids. A capability's settings section is not gated: Settings.tsx maps composition.settings into The app with no developer test
3 Shell, Book content cannot reach native Shipped, and "the live check has not been run", in one cell The legend defines Shipped as verified in the app. Nothing ran make-hostile-epub.py — four comments, no test, no verify step, no workflow. Set to Partial, then RUN the same day and set back to Shipped — see What running it found below
4 The circle, end to end Partial "because no run has yet covered the shelf and jacket half (WI-24.C3)" WI-24.C3 closed 2026-09-08 and the plan carries the heading in those words. Part 4 recorded it on 09-10; this cell did not
5 Part 3, the circle's matrix note ◐ "for one reason: the two-machine run has never happened" Four days void. Struck

Two smaller ones, both in Where cells: Space between CJK and Latin pointed at Paragraphs and the control is the last row of Spacing; and Content-derived book identity named core/marks.ts, which re-exports the sampling geometry the row is Partial for and explains in a comment that it lives in contentIdentity.ts. And a count: word-boundary snapping said 12 modules and the directory has never held 12 — ten on 2026-08-19, thirteen since the gloss added three on 08-24.

What running it found

The check was run the same day it was recorded as never having been run, and it did four things no reading of the tree could have done.

1. It passed. The book rendered and #paper-isolation-verdict still held its default. A false pass was ruled out before the result was believed — the script parses under node --check, every probe is try/catched, and the write that would replace the paragraph is the script's last statement.

2. The mechanism was not the CSP. scriptCount in the book's own document is 0. A CSP blocks execution and leaves the element; bookScripts.ts strips it at the loader. That file is honest about being "defence in depth" behind the CSP — this document was not, and named the wall that never got the chance to act.

3. The fixture cannot tell its two layers apart, and cannot while stripping works. On the desktop make-hostile-epub.py can only ever return SCRIPT DID NOT RUN. That is worth knowing before the next person reads a pass from it as evidence about the CSP: it is evidence about the strip.

4. The two defences are one door with two locks. Measured from inside the book's document, which nothing had ever done:

What was read What it answered
origin http://localhost:14201 — the app's own
sandbox allow-same-origin allow-scripts
window.__TAURI__ / parent.__TAURI__ absent — withGlobalTauri: false works
parent.__TAURI_INTERNALS__ / top.__TAURI_INTERNALS__ present

The internals object is the IPC channel and the fixture lists it as a breach. So withGlobalTauri: false removes the convenience global and not the route. A row that names two mechanisms should not be read as two walls.

And scripts/csp-effect.mjs was run, for what is probably the first time in a while. It passes, with the control that makes it worth trusting: the strict policy blocks inline, external and framed script in WebKit and Chromium, and the deliberately widened policy reports RAN on all three. ⚠️ It weighs paper-webhost's policy, not tauri.conf.json's — the browser client's wall — and nothing in verify or CI invokes it. Five comments cite it; no runner does.

Two traps the run hit, which are why it had never been run

⚠️ THE CHECK CANNOT RUN BESIDE A RUNNING PAPER, AND THE SECOND PROCESS EXITS 0. single_instance is registered before every other plugin and before setup, deliberately — a newcomer hands its argv to the first process and exits there. A second build therefore never opens a window, never reaches the library lock, and reports success. pnpm tauri dev spent 3m55s compiling, ran target/debug/app, and exited 0 with nothing to show, which is the exact shape of actool and screencapture in AGENTS.md wearing a fourth hat.

⚠️ AND PAPER_TEST_DATA_DIR DOES NOT ISOLATE THE BOOK VAULT. The Rust side honours it — peer/identity.key and inference/ landed under the scratch root. The vault did not: opening the fixture wrote books/book_28d0dc6…/ into the real library, with an entry in index.json and a line in sync/journal.jsonl, where a real shelf would have replicated it. The override is paper-data-root's, read by Rust; the vault resolves $APPDATA through the fs plugin, which never consults it. A scratch root that isolates half of the app is worse than none, because it is believed.

⚠️ AND IT IS DELIBERATE, WHICH THE FIRST WRITING OF THIS PARAGRAPH DID NOT SAY. src-tauri/src/atomic.rs has carried the reason all along: the kernel's root is the fs plugin's AppData "deliberately — the kernel reads every file it writes back through that plugin, so a write that landed anywhere else would be a write the kernel could not read", and the override "moves the peer plugin's files and not the kernel's (the trap WI-8.6 recorded) … that discrepancy is noted, not hidden." So this is a decision with a stated cost, not a defect, and there is no way to give the vault a scratch root at all. Written down because the first version of this note implied a fix was pending, which would have sent the next person to repair a decision — the same error this pass found five times in the rows above, committed in the act of recording them. Removed through paper book remove, which is the app's own path and leaves a correct tombstone rather than a dangling index entry.

What this pass actually establishes

UNFINISHED_PANE_IDS hides a PANE, and this document has been reading it as a policy. AGENTS.md states the rule as "the only edit required, because the rail, the palette, the digit accelerators and the Settings band all read one rule" — four surfaces, every one of them the desktop's. src/app/mobile/ and src/main.web.tsx read none of it and never have. So an unfinished panel is hidden on one of three shells, shipping one is not a one-line edit, and the sentence that says it is appears in the project's tracked instructions where every agent reads it.

The gate cannot see any of this and should not be asked to. Findings 1 and 2 are a document asserting a property of code it does not name; 3 is a state cell disagreeing with its own prose; 4 and 5 are one part of this file disagreeing with another. The nearest thing to a mechanical check is the last two: a row whose State cell and How to confirm cell contradict each other is findable, and Book content cannot reach native carried "Shipped" beside "has not been run" through four passes.

What this does not close

Parts 2 and 3 are still 2026-08-21 research and are now demonstrably the weakest half of this document. Two of the three notes attached to its two matrices were arguing from states that had moved — the circle's, and the AI assistance ● whose justification is a panel behind ⌘⌃⌥D. Neither was found by re-reading the matrix; both were found by reading Part 1's rows and then looking for anything else that cited them. The ten comparator columns have not been re-checked since the day they were placed.

Public sharing still has no row in any matrix, which the 09-10 pass named and this one did not close either, for its reason: putting it in "What Paper has that most do not" means re-reading ten comparators.

Nothing was driven in a running app. Every finding here is from the tree and from this document. The claims a pass like this cannot reach are exactly the ones finding 3 is about, and the public layer's whole section inherits the same limit by its own preamble — nineteen Shipped rows under a header saying nothing below has crossed two machines. That reading is deliberate and stated, so it is left standing rather than re-graded here; it is written down so the next pass decides it on purpose.


The 2026-09-12 pass

At 0.3.4, after a whole-codebase audit and the twelve fixes it produced. Two releases since the last pass, and this is the first one driven by code that MOVED rather than by re-reading rows against a tree that had not. Thirteen commits: twelve fixes and the version bump.

pnpm ledger:check is green before and after — 195 rows then, 198 now, 0 findings both times, 122 declared names covered. Not one of the findings below was visible to it, which is the same lesson the 09-11 pass drew: the gate asks whether a path resolves, whether a state cell is one of five, and whether every registry name appears somewhere. It cannot ask whether a sentence is true.

Four rows the new code moved under

Row What it said What is true now
Two pairing kinds, two grants the grant is the whole gate, and "Both circle services" Four of the five services ask the caller's standing as well. circle:read is written once at pairing and outlives forgetting a person, blocking, exiting and revoking a device — and pages was the one that asked nothing
A resource model for strangers Where was share/bounds.rs alone Three bounds live outside it: two per-connection caps in notes.rs and the crate's single DIAL_TIMEOUT
Receiving somebody else's notes the door checks "the reader's block list" It checks it for a PUBLICATION only. A silenced voice's unnote is admitted deliberately
The Publish pane "offers two switches" It draws a third thing above both, because it contradicts them: this device is serving nothing

Four rows that were already stale when the gate went green

Two of them are the same defect the 09-11 pass named — a row edited in isolation while the row it argues with was left alone.

  1. Receiving somebody else's notes was Partial because "nothing has driven it in the app". scripts/public-drive.mjs presses Look for some in the running app, and the row two above it records eight records crossing between two Macs on 2026-09-11, driven through the app's own controls. One edit flipped that row and left this one saying the opposite. Partial → Shipped.
  2. The Publish pane carried the same void half. Its OTHER half — no capture — survives, so the state stands and the reason is narrowed to the half that is true.
  3. Two pairing kinds, two grants said "Both circle services"; there are five, and line 505 of this same document already said five.
  4. A second endpoint, on a second key listed the routes that start the share endpoint and omitted resume_share, which starts it at launch. The row's conclusion survives, because it returns early with nothing offered.

Three controls a reader operates that had no row at all

Every one is a decision a person makes in the app, and every one was covered only as machinery underneath it:

What this pass did not re-grade

The public section's preamble still reads "nothing below has crossed two machines" over rows that now have. Row 432 records the 2026-09-11 crossing and two rows in that section have been re-graded on it, which leaves the preamble arguing against its own rows. The 09-11 pass saw this and left it deliberately, to be decided on purpose rather than in passing; it is still the largest thing in this document that one pass should settle, and it is still not settled.

And the other five audit fixes touched nothing this document claims. Circle re-admission, markStore.rekey, recordStamp, the library-lock fsync and the dropped kernel re-exports all repair defects no row described either way. A ledger that had a row for each would have been a ledger nobody could read; that they are absent is not a gap.