backdraft 0.8.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. {backdraft-0.8.0 → backdraft-0.9.0}/.claude-plugin/plugin.json +1 -1
  2. {backdraft-0.8.0 → backdraft-0.9.0}/DESIGN.md +5 -1
  3. {backdraft-0.8.0 → backdraft-0.9.0}/PKG-INFO +37 -4
  4. {backdraft-0.8.0 → backdraft-0.9.0}/README.md +34 -3
  5. backdraft-0.9.0/ROADMAP.md +393 -0
  6. {backdraft-0.8.0 → backdraft-0.9.0}/SPEC.md +26 -10
  7. {backdraft-0.8.0 → backdraft-0.9.0}/demo/memo.backdraft.html +11 -5
  8. {backdraft-0.8.0 → backdraft-0.9.0}/demo/walkthrough.md +101 -8
  9. {backdraft-0.8.0 → backdraft-0.9.0}/pyproject.toml +4 -1
  10. {backdraft-0.8.0 → backdraft-0.9.0}/site/demo.html +11 -5
  11. {backdraft-0.8.0 → backdraft-0.9.0}/site/docs.html +24 -10
  12. {backdraft-0.8.0 → backdraft-0.9.0}/site/llms.txt +35 -4
  13. {backdraft-0.8.0 → backdraft-0.9.0}/skills/backdraft/SKILL.md +31 -0
  14. {backdraft-0.8.0 → backdraft-0.9.0}/skills/backdraft-artifact/SKILL.md +89 -52
  15. {backdraft-0.8.0 → backdraft-0.9.0}/skills/backdraft-backfill/SKILL.md +4 -0
  16. {backdraft-0.8.0 → backdraft-0.9.0}/spec/artifact.md +24 -6
  17. backdraft-0.9.0/spec/registry.md +332 -0
  18. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/bind/binder.py +18 -24
  19. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/bind/cli.py +66 -19
  20. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/cli.py +7 -1
  21. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/cli_context.py +42 -3
  22. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/gate/cli.py +4 -1
  23. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/gate/searcher.py +27 -4
  24. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/kernel/artifact.py +27 -7
  25. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/registry/__init__.py +2 -0
  26. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/registry/store.py +111 -15
  27. backdraft-0.9.0/src/backdraft/render/cli.py +468 -0
  28. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/html/assets.py +5 -0
  29. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/html/components.py +18 -0
  30. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/html/page.py +17 -7
  31. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/markdown.py +41 -9
  32. backdraft-0.9.0/src/backdraft/render/math.py +145 -0
  33. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/sidecar.py +55 -0
  34. {backdraft-0.8.0 → backdraft-0.9.0}/tests/gate/fake_registry.py +8 -3
  35. {backdraft-0.8.0 → backdraft-0.9.0}/tests/gate/test_gate_searcher.py +81 -1
  36. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/render/backfill.sidecar.json +5 -4
  37. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/render/demo.sidecar.json +5 -4
  38. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_bind_cli.py +129 -1
  39. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_gate_integration.py +20 -0
  40. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_registry.py +34 -0
  41. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_render_cli.py +60 -1
  42. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_render_markdown.py +33 -0
  43. backdraft-0.9.0/tests/test_render_math.py +217 -0
  44. backdraft-0.9.0/tests/test_spec_registry.py +324 -0
  45. backdraft-0.9.0/tests/test_verify_cli.py +564 -0
  46. {backdraft-0.8.0 → backdraft-0.9.0}/uv.lock +15 -2
  47. backdraft-0.8.0/ROADMAP.md +0 -340
  48. backdraft-0.8.0/src/backdraft/render/cli.py +0 -184
  49. {backdraft-0.8.0 → backdraft-0.9.0}/.claude-plugin/marketplace.json +0 -0
  50. {backdraft-0.8.0 → backdraft-0.9.0}/.github/workflows/ci.yml +0 -0
  51. {backdraft-0.8.0 → backdraft-0.9.0}/.github/workflows/release.yml +0 -0
  52. {backdraft-0.8.0 → backdraft-0.9.0}/.github/workflows/site.yml +0 -0
  53. {backdraft-0.8.0 → backdraft-0.9.0}/.gitignore +0 -0
  54. {backdraft-0.8.0 → backdraft-0.9.0}/.python-version +0 -0
  55. {backdraft-0.8.0 → backdraft-0.9.0}/LICENSE +0 -0
  56. {backdraft-0.8.0 → backdraft-0.9.0}/assets/backdraft-mark.svg +0 -0
  57. {backdraft-0.8.0 → backdraft-0.9.0}/demo/generate_sources.py +0 -0
  58. {backdraft-0.8.0 → backdraft-0.9.0}/demo/memo.md +0 -0
  59. {backdraft-0.8.0 → backdraft-0.9.0}/demo/sources/t12-summary.pdf +0 -0
  60. {backdraft-0.8.0 → backdraft-0.9.0}/demo/sources/underwriting-model.xlsx +0 -0
  61. {backdraft-0.8.0 → backdraft-0.9.0}/site/index.html +0 -0
  62. {backdraft-0.8.0 → backdraft-0.9.0}/site/privacy.html +0 -0
  63. {backdraft-0.8.0 → backdraft-0.9.0}/spec/chunking.md +0 -0
  64. {backdraft-0.8.0 → backdraft-0.9.0}/spec/tokens.md +0 -0
  65. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/__init__.py +0 -0
  66. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/__main__.py +0 -0
  67. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/bind/__init__.py +0 -0
  68. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/bind/evidence.py +0 -0
  69. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/bind/verify/__init__.py +0 -0
  70. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/bind/verify/base.py +0 -0
  71. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/bind/verify/entail.py +0 -0
  72. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/bind/verify/overlap.py +0 -0
  73. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/bind/verify/recompute.py +0 -0
  74. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/bind/verify/value_trace.py +0 -0
  75. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/credentials.py +0 -0
  76. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/__init__.py +0 -0
  77. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/base.py +0 -0
  78. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/csv.py +0 -0
  79. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/docx.py +0 -0
  80. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/html.py +0 -0
  81. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/image.py +0 -0
  82. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/pdf_text.py +0 -0
  83. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/pptx.py +0 -0
  84. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/sheet.py +0 -0
  85. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/snapshots.py +0 -0
  86. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/text.py +0 -0
  87. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/vlm.py +0 -0
  88. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/vlm_settings.py +0 -0
  89. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/xls.py +0 -0
  90. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/extract/xlsx.py +0 -0
  91. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/fetch.py +0 -0
  92. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/gate/__init__.py +0 -0
  93. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/gate/reader.py +0 -0
  94. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/kernel/__init__.py +0 -0
  95. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/kernel/chunking.py +0 -0
  96. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/kernel/claims.py +0 -0
  97. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/kernel/errors.py +0 -0
  98. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/kernel/hashing.py +0 -0
  99. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/kernel/model.py +0 -0
  100. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/kernel/tokens.py +0 -0
  101. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/registry/ledger.py +0 -0
  102. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/registry/schema.sql +0 -0
  103. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/__init__.py +0 -0
  104. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/_text.py +0 -0
  105. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/footnotes.py +0 -0
  106. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/html/__init__.py +0 -0
  107. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/html/fmt.py +0 -0
  108. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/html/fmt_vectors.py +0 -0
  109. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/html/text.py +0 -0
  110. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/placement.py +0 -0
  111. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/theme.py +0 -0
  112. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/themes/default.toml +0 -0
  113. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/themes/press.toml +0 -0
  114. {backdraft-0.8.0 → backdraft-0.9.0}/src/backdraft/render/themes/slate.toml +0 -0
  115. {backdraft-0.8.0 → backdraft-0.9.0}/tests/conftest.py +0 -0
  116. {backdraft-0.8.0 → backdraft-0.9.0}/tests/conftest_registry.py +0 -0
  117. {backdraft-0.8.0 → backdraft-0.9.0}/tests/conftest_render.py +0 -0
  118. {backdraft-0.8.0 → backdraft-0.9.0}/tests/css_util.py +0 -0
  119. {backdraft-0.8.0 → backdraft-0.9.0}/tests/fakes.py +0 -0
  120. {backdraft-0.8.0 → backdraft-0.9.0}/tests/gate/conftest.py +0 -0
  121. {backdraft-0.8.0 → backdraft-0.9.0}/tests/gate/test_gate_cli.py +0 -0
  122. {backdraft-0.8.0 → backdraft-0.9.0}/tests/gate/test_gate_ledger.py +0 -0
  123. {backdraft-0.8.0 → backdraft-0.9.0}/tests/gate/test_gate_reader.py +0 -0
  124. {backdraft-0.8.0 → backdraft-0.9.0}/tests/gate/test_reader_cells.py +0 -0
  125. {backdraft-0.8.0 → backdraft-0.9.0}/tests/gate/test_reader_show.py +0 -0
  126. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/bind/backfill.bound.md +0 -0
  127. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/bind/frontwalk.bound.md +0 -0
  128. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/chunking/long_tail.json +0 -0
  129. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/chunking/long_tail.txt +0 -0
  130. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/chunking/many_small.json +0 -0
  131. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/chunking/many_small.txt +0 -0
  132. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/chunking/mixed.json +0 -0
  133. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/chunking/mixed.txt +0 -0
  134. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/chunking/one_giant.json +0 -0
  135. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/chunking/one_giant.txt +0 -0
  136. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/chunking/short_page.json +0 -0
  137. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/chunking/short_page.txt +0 -0
  138. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/chunking/unsplittable.json +0 -0
  139. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/chunking/unsplittable.txt +0 -0
  140. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/render/backfill.footnotes.md +0 -0
  141. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden/render/demo.footnotes.md +0 -0
  142. {backdraft-0.8.0 → backdraft-0.9.0}/tests/golden_util.py +0 -0
  143. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_bind_binder.py +0 -0
  144. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_bind_evidence.py +0 -0
  145. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_bind_verify.py +0 -0
  146. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_card_sizing.py +0 -0
  147. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_chunking.py +0 -0
  148. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_claims.py +0 -0
  149. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_cli.py +0 -0
  150. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_credentials.py +0 -0
  151. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_docs.py +0 -0
  152. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_extract.py +0 -0
  153. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_extract_html.py +0 -0
  154. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_fetch.py +0 -0
  155. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_fmt_parity.py +0 -0
  156. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_footnotes.py +0 -0
  157. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_hashing.py +0 -0
  158. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_html.py +0 -0
  159. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_ingest_url.py +0 -0
  160. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_invariants.py +0 -0
  161. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_model.py +0 -0
  162. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_placement.py +0 -0
  163. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_records_location.py +0 -0
  164. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_reingest.py +0 -0
  165. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_sidecar.py +0 -0
  166. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_snapshot_settings.py +0 -0
  167. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_snapshots.py +0 -0
  168. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_theme.py +0 -0
  169. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_tokens.py +0 -0
  170. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_value_trace.py +0 -0
  171. {backdraft-0.8.0 → backdraft-0.9.0}/tests/test_version.py +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "backdraft",
3
3
  "displayName": "Backdraft",
4
- "version": "0.8.0",
4
+ "version": "0.9.0",
5
5
  "description": "Drop-in provenance for factual claims: one click from any claim to the evidence behind it. Ships the backdraft writing, backfill, and artifact-reading skills; the CLI installs separately (uv tool install backdraft).",
6
6
  "author": {
7
7
  "name": "Spencer Braun",
@@ -29,7 +29,7 @@ Four verbs:
29
29
  - **`ingest`** — deterministically anchor source documents (PDF/xlsx/md/…) into the registry: extract, locate, snapshot snippets, hash.
30
30
  - **`read` / `search`** — the gate. Emit token-marked context for a model. Search results are themselves mintable evidence (search that returns nothing citable forces a page read purely to obtain an anchor).
31
31
  - **`bind`** — postprocess a written document: resolve tokens, run enabled verification switches, backfill surface forms (doc names, pages, quotes), emit sidecar claims file + binding report.
32
- - **`render`** — produce the clickable artifact.
32
+ - **`render`** — produce the clickable artifact. Its reader half is `verify`: an artifact carries the checks it wants run in its own legend, so the tool that writes them ships the command that runs them.
33
33
 
34
34
  ### 3. Substrates (sequenced)
35
35
  1. **Skill + CLI, together** (first release). The CLI is the system; the skill is ~a page instructing an agent to use it. Ships both the agent-native wedge and the zero-integration backfill demo.
@@ -147,6 +147,10 @@ Self-contained HTML (plus the machine-readable sidecar): **quotes + receipts + e
147
147
  | 2026-08-18 | **One rule names a source, and every surface that names one uses it.** A fetched page's `filename` is the temporary file `fetch.filename_for` staged the bytes in — `index.html` for a permanent link, a name on nobody's disk — and the 2026-08-06 row settled what to do about it ("showing both would give a reader two names for one thing and let the fictional one look authoritative") for the artifact alone. The four CLI surfaces that name a document kept the fiction: `ingest`'s line, the gate's document list, its table-of-contents headline, and `ls` — which showed *both*, filename in column two and URL in a fifth field, the exact shape that row rejected. `gate.source_name` is now the single owner, exported the way `gate.unit` is and imported downward by `cli.py`, so a writing agent learns a source's provenance from the surface it already lives on instead of being sent to `ls` by the skill. Display only: the slug, the token and everything a citation resolves through are untouched, which is the 2026-08-05 "provenance, never identity" rule. Two consequences named rather than discovered. `ls` loses a field — a fetched row is four columns, not five — so anything parsing column five stops seeing one; that is the point, since column two was a lie and one true name beats two names of which one is fictional. And in the gate's aligned list a URL *overflows* the name column rather than widening it: the column is sized on filenames alone, so one 80-character address pushes its own row's later columns right instead of pushing every row's past column 100. The cost is a ragged right on fetched rows, accepted because the file rows a mostly-file registry is made of stay byte-identical to what they printed before — pinned by a test, the way the artifact's byte-identity is. `bind`'s unmatched-claim report still names a source by filename: the fix cannot be shared into it without a sideways `bind` → `gate` import, which SPEC § Dependency rule forbids, and duplicating the rule there is the reinvention this row exists to end. |
148
148
  | 2026-08-19 | **A slug is assigned by a rule, and the rule is written down.** The Open list carried "slug assignment/collision rules" from the first field trial, and URL sources sharpened it into a live failure: `fetch.filename_for` took the stem from a URL's last path segment, so `https://a.example/reports/2025/index.html` and `https://b.example/docs/index.html` both staged as `index.html` and became `index` and `index-2` — two handles that say which is which to nobody, carried by every token in every document written against them, permanently, since a slug is stable once assigned. The rule now has two clauses and they are stated together for files and URLs. A **file** is named by its filename's stem, kebabed and cut to 32 characters, which is unchanged and was never the problem. A **URL** is named by its last path segment, *except* when that segment names the site's plumbing rather than its page — a directory index or generic handler (`_GENERIC_STEMS`: `index`, `view`, `page`, `show`, and a dozen more), an all-digit id, or no segment at all — in which case the host goes in front of it, minus `www.` and minus the port, so the two pages above become `a-example-index` and `b-example-index`. The common case is deliberately untouched: `https://example.com/reports/q4-2025` is still `q4-2025`, because a rule that made every good name uglier to fix the bad ones would be the wrong trade. `_dedupe`'s `-2` stays, demoted from the normal outcome to the backstop it was meant to be — the host is a *better* stem, not a unique one, and two `index.html` pages on one site still collide. Truncation is trimmed on both cuts now (`slug_for` and `_dedupe`), which is the other half of the Open line: a 32-character cut lands on a hyphen often enough that `q4-report-` was a slug this repo could produce, legal in the grammar and reading like a bug. The compatibility statement, said out loud because nothing else will say it: two registries built on either side of this change disagree about a fetched page's slug, and an artifact's tokens are not portable between them. Existing registries are unaffected, and that is not luck — `_find_document` matches a re-fetch on the URL, never the slug, so re-ingesting a page keeps the slug it already has. `--slug` remains the way to overrule all of this and is still the advice for a fetched page worth citing, because `a-example-index` names a site rather than nothing, which is better and is not good. |
149
149
  | 2026-08-20 | **`ingest` says which of its three outcomes happened, and how much text came out.** One line served three different events — a document created, a *new generation* of one whose bytes moved, and a no-op that wrote nothing — so an agent re-ingesting after a fix could not tell whether anything had changed, at exactly the moment it most needed to: a new generation is when citations into the previous snapshot begin resolving to a superseded one and `bind` starts calling them `drifted`. The registry already distinguished all three internally (`_find_document`, `_is_noop`, `_upsert_document`) and threw the answer away, so `ingest` now returns an `Ingested` — a `Document` subclass carrying `outcome`, the shape `SearchResults` established for `search`: Addendum A's pinned `-> Document` still holds, every `.slug` caller and every fake is untouched, and the one consumer that cares reads the extra field. Re-deriving the outcome in the CLI was the alternative and would have meant a second implementation of `_is_noop` living away from the first. The wart is named rather than papered over: a dataclass compares by class, so an `Ingested` never equals the plain `Document` the read side returns for the same row, and the two tests that compared them now compare what persists. The second half is a number: the extracted character count, beside the page count on every line. A scanned PDF with no text layer and a page behind a login both ingest cleanly, exit 0 and print a page count like any success, which is how a shell of a source gets cited — and the writing skill's instruction to notice "thin or empty" gave the agent nothing to notice it with. Under `THIN_SOURCE_CHARS` (200) a note names the likely cause by media type and what to do about it. That threshold is a heuristic, which this repo refuses elsewhere on purpose — the `html` extractor is a parse and not a readability guess, because a heuristic that changes its mind moves anchors. This one is admissible for the reason those are not: it is display only, at exit 0, and no token, anchor, status or byte of the artifact derives from it, so being wrong costs one line of advice and never a citation. `chars` counts sheets too, where `read`'s window unit is rows: this is the extraction's volume, not a window into it. Grouping follows the 2026-08-13 rule — thin sources group by cause, since one missing text layer explains every scan at once and each document's own count is already on its own line, while the failure report still lists by source. |
150
+ | 2026-08-24 | **`backdraft verify` — the recipient's check, and the drift bug it found in the spec.** The artifact's whole promise is that someone holding only the file can check it, and until now the instructions for doing so were prose: `skills/backdraft-artifact/SKILL.md` walked an agent through recomputing hashes by hand, which is a deterministic procedure re-implemented per reading — the way a recipient's audit ends up weaker than the producer's. It is now a command, in `render/cli.py` because that module already holds the format's reader half (`render.sidecar`), taking either the `.backdraft.html` or the `.backdraft.json`. Two tiers, and the output names which ran: the record against itself, which needs nothing but the file, and — only where a `.backdraft/` is discoverable — every token re-resolved against the sources. Discovery is from **cwd**, not from the artifact's own directory as `bind` does it from the document's: an artifact is a file people forward, so the folder it landed in is evidence about the recipient's machine and not about which registry produced it. Exit 2 joins `bind`'s, which was documented as "`bind`'s alone"; the shared meaning is "the run completed and the document is not clean", which is exactly what a Stop hook gates on, so a hook written for one now catches the other — the gate's `show` stays at 1, because a lookup answering "that token names nothing" is not a document failing. Read-only and minting nothing, which is the line between it and `show`: showing is minting, and an audit that made its own subject citable would manufacture the evidence it was sent to inspect. A record faithfully carrying an `unresolved` citation still passes tier one — a kept failure is the record working, so the same bytes exit 0 in an inbox and 2 in the project, answering different questions. Two consolidations came with it rather than a third copy of each: `registry.citation_for` is the token-to-status walk `bind` and `verify` both need and the dependency rule forbids either importing from the other, so it sits beside `current_at` for the same reason, with `not_shown` deliberately left out as a fact about a session the caller holds; and `cli_context.claim_words` is the line-item shape, since two commands reporting on the same claims must not print two layouts. The real find was in the spec. `spec/artifact.md` § Checking an artifact and the legend's `verify_this_record` both said the token's hash must prefix `anchor.snippet_sha256` — false for every `drifted` citation by construction, since the token names `drifted_from` (what the author cited) while `anchor` carries what stands there now, and the two differing *is* what drift means. Implemented as written, `verify` would have called every drifted artifact forged: the one status the format exists to carry honestly. Both statements are corrected, which moves the legend and therefore every artifact's bytes — goldens and the demo regenerated — and is worth the churn, because a known-wrong normative instruction shipped inside every artifact is a worse cost than a diff. |
151
+ | 2026-08-24 | **Math is rendered, not forbidden, and MathML is why that is affordable.** An agent writing a memo about coverage writes `$\mathrm{DSCR} = \frac{NOI}{D}$`, and the renderer handed the reader raw TeX — or worse, corrupted TeX, since `$x_1$ and $x_2$` paired its subscripts into emphasis and `\(a^2\)` lost its delimiters to the backslash-escape pass. The alternative on the table was to forbid math in the writing skill, and it was rejected twice over: this product's documents are exactly the finance and science memos that contain formulas, and a skill instruction cannot be enforced against an agent that did not read it carefully, so the corruption would have arrived anyway. Shipping *unrendered* TeX was rejected too — nobody wants to read `\frac{NOI}{D}`, and a document a reader cannot read is not a lesser artifact but a broken one. The target is MathML via `latex2mathml`, behind a `[math]` extra: it is static markup the browser lays out itself, so rules 1 and 2 of § The HTML artifact hold *by construction* rather than by care — no script, no font file, no request — where inlining KaTeX would have cost roughly a megabyte of fonts as `data:` URIs on every artifact, math or not. The tradeoff, stated because it is the one place the format's fixed-at-render promise is soft: MathML leans on whatever math font the reader's system has, so two readers of one artifact may see different glyph quality. That is a smaller and more honest cost than the alternative, and it is bounded — the markup, the spacing, and every character are identical; only the typeface is not. Dollars were the real hazard and the guard is Pandoc's: a memo saying `$250 per unit` and `$1.2M` twice in a paragraph would pair them into nonsense under a naive rule, so an opening `$` must be followed by a non-space and a closing `$` must be preceded by one and not followed by a digit — a currency amount is preceded by a space and therefore can never close a span. Code is skipped rather than scanned, so a shell snippet's `$PATH` and `$HOME` are safe, and the demo regenerates with zero math elements, which is the check that matters. Math is opt-in per call and **off for evidence**: a receipt's snippet must read as it was extracted, not as this renderer would prefer it. Conversion failure is data, at two different reliabilities — `latex2mathml` raises for some malformed input, which surfaces as a wavy mark at the formula and a note giving the reason, and silently degrades for the rest (`\notacommand{x}` becomes an identifier), which cannot be caught and is recorded here as a known limit rather than papered over. |
152
+ | 2026-08-25 | **The registry export is a specified format, or it is not a format.** `backdraft export` has written `"$format": "backdraft/registry-v1"` since v0 — a version string promising a specification that did not exist — while being the only complete, portable form a registry has, and therefore what a second implementation, a migration or an audit actually reads. The gap stopped being theoretical when URL sources added a conditional `meta` key to every exported document: a change to a format with nothing to change against. So spec/registry.md joins tokens, chunking and artifact as normative prose, and the pin is the spec itself rather than a golden file beside it — tests/test_spec_registry.py parses the spec's key tables, exports a registry built to exercise every branch, and fails in both directions, so an undocumented key and a documented-but-absent one are equally a test failure. Two boundaries were drawn rather than blurred. `bindings[].report` stays spec/artifact.md's, quoted opaquely here, because one format with two specifications is one too many and they would drift. And the export is content, not a backup: page images, sheet styling and the FTS index are deliberately outside it, so an export round-trips every citation and every receipt and does not round-trip a database — stated in the spec as a limit rather than left for a reader to discover by restoring one. |
153
+ | 2026-08-26 | **`search` reports the size of the answer, not the size of the page.** `--limit` defaults to 20 and the count line said `20 results` whether twenty matched or two hundred did, which is the silent cap this repo rejects everywhere else — `read` closes with `[Showing 0-464 of 3115 chars. Continue with: …]` for exactly this reason. Search is the surface an agent leans on hardest, since it is how a claim finds its evidence, and relevance order is not the same as the sentence the writer needed: a reader told it has twenty results picks the best of twenty and never learns the twenty-first existed. So `SearchResults` gains `total` beside `phrase_fallback` — the same shape for the same reason, an out-of-band fact riding on Addendum A's pinned `list[SearchHit]` so every consumer and every fake is untouched — and a capped run renders `2 of 56 results` plus a closing line naming the exact widening command, shell-quoted, because a hint carrying `$` or a space that has to be repaired before it runs is not a hint. The count is a `COUNT(*)` over the *same* joins the fetch runs, shared through `_match_sql` rather than written twice: a count that joined differently would report a total the caller can never reach. It is asked for only when the page came back full, since a page the limit did not fill is already the whole answer — so the common uncapped run still runs one query, and the output it prints is byte-identical to what it printed before, pinned as a test the way the artifact's bytes are. Two things were deliberately not built. No `--offset`: paging a relevance-ordered list is a different design question, and `--limit` already exists for the hint to name. And the total is not carried into anything durable — no token, anchor, receipt or artifact byte moves, because how many other sentences also matched is a fact about a query, not about a citation. |
150
154
 
151
155
  ## Open
152
156
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: backdraft
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Drop-in provenance for factual claims: one click from any claim to the evidence behind it.
5
5
  Project-URL: Homepage, https://backdraft.dev
6
6
  Project-URL: Documentation, https://backdraft.dev/docs.html
@@ -18,6 +18,8 @@ Requires-Dist: python-pptx>=1.0
18
18
  Requires-Dist: typer>=0.12
19
19
  Provides-Extra: entail
20
20
  Requires-Dist: anthropic>=0.40; extra == 'entail'
21
+ Provides-Extra: math
22
+ Requires-Dist: latex2mathml>=3.77; extra == 'math'
21
23
  Provides-Extra: vlm
22
24
  Provides-Extra: xls
23
25
  Requires-Dist: python-calamine>=0.3; extra == 'xls'
@@ -128,6 +130,11 @@ $ backdraft search "24850000"
128
130
  [Read the page: backdraft read underwriting-model p2]
129
131
  ```
130
132
 
133
+ Search shows twenty results by default. A run that hit the limit says so —
134
+ `20 of 56 results` — and closes with the command that shows the rest, because
135
+ "20 results" over an answer of 56 is how a reader concludes it has seen
136
+ everything when it has seen a third.
137
+
131
138
  A cell you can already see mints directly:
132
139
 
133
140
  ```console
@@ -170,6 +177,7 @@ bound 17 claim(s), 18 citation(s) [frontwalk]
170
177
  resolved: 17
171
178
  unresolved: 1
172
179
  overlap: pass 13, skip 4
180
+ skip 4 — wording overlap does not apply to a single cell
173
181
  value-trace: pass 17
174
182
  ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @3629
175
183
  wrote .backdraft/records/memo.backdraft.json
@@ -193,6 +201,29 @@ memo.backdraft.html
193
201
  That file is the whole deliverable: send it over email or Slack and the
194
202
  recipient gets the full experience by double-clicking it.
195
203
 
204
+ **The recipient can check it.** `backdraft verify` takes the artifact or its
205
+ sidecar and does the checking that the file is built to make possible: every
206
+ snippet rehashed against the sha256 recorded beside it, every token checked
207
+ against the anchor it names, `summary` recounted from the claims.
208
+
209
+ ```console
210
+ $ backdraft verify memo.backdraft.html
211
+ checked memo.backdraft.html [backdraft/artifact-v1]
212
+ receipts: 17 of 17 hold
213
+ record: 17 claim(s), 18 citation(s); the summary recount agrees
214
+ recorded: resolved 17, unresolved 1
215
+ sources: no .backdraft/ found from here — not re-checked
216
+ [Re-check against the sources: run this inside the project it was bound in.]
217
+ ```
218
+
219
+ That first tier needs nothing but the file, which is the point — it is the check
220
+ someone who was emailed the artifact can still make. Run it inside the project
221
+ the document was bound in and it also re-resolves every citation against the
222
+ registry, so a source that has changed since shows up as `drifted` rather than
223
+ going unnoticed. Exit 0 when everything it checked passed, 2 when something did
224
+ not, so a hook can gate on it. It opens no session and mints nothing: an audit
225
+ must not make its subject citable.
226
+
196
227
  **Theming.** The artifact ships with a default look and two alternates,
197
228
  `press` and `slate`:
198
229
 
@@ -320,12 +351,13 @@ the keyless text layer and says so — still storing each page's image, which it
320
351
  renders locally. PDF page rendering needs poppler, installed separately; without
321
352
  it ingest succeeds without the images and names `snapshot-pages` as the backfill.
322
353
 
323
- Two extras:
354
+ Three extras:
324
355
 
325
356
  | Extra | Adds | For |
326
357
  |---|---|---|
327
358
  | `[entail]` | `anthropic` | `bind --check entail`, the model-judge verifier |
328
359
  | `[xls]` | `python-calamine` | legacy `.xls` workbooks, values only, rendered the same shape as xlsx |
360
+ | `[math]` | `latex2mathml` | LaTeX in a document becomes MathML in the artifact; without it, formulas render verbatim and `render` says so |
329
361
 
330
362
  `backdraft[vlm]` still installs: it is an empty compat alias from when the
331
363
  vision deps were an extra.
@@ -346,7 +378,7 @@ Three agent skills ship inside the package and in this repo:
346
378
  |---|---|
347
379
  | [`backdraft`](skills/backdraft/SKILL.md) | writing a new document from sources, citing as it goes |
348
380
  | [`backdraft-backfill`](skills/backdraft-backfill/SKILL.md) | attributing a document that already exists |
349
- | [`backdraft-artifact`](skills/backdraft-artifact/SKILL.md) | reading someone else's artifact cold |
381
+ | [`backdraft-artifact`](skills/backdraft-artifact/SKILL.md) | checking and reading someone else's artifact cold |
350
382
 
351
383
  The CLI is the system; a skill is one page telling an agent to use it. Nothing in
352
384
  the skills is required to use backdraft by hand. How the skills reach your agent
@@ -404,9 +436,10 @@ spans are citable. In a sandboxed session run every command as
404
436
  | [spec/tokens.md](spec/tokens.md) | the citation token grammar, normatively |
405
437
  | [spec/chunking.md](spec/chunking.md) | the deterministic chunker |
406
438
  | [spec/artifact.md](spec/artifact.md) | the artifact format, sidecar payload, legend, HTML rules |
439
+ | [spec/registry.md](spec/registry.md) | the registry export format `backdraft export` writes |
407
440
  | [demo/walkthrough.md](demo/walkthrough.md) | the whole thing end to end, with real output |
408
441
 
409
- The three files under `spec/` are the portable specification: another
442
+ The four files under `spec/` are the portable specification: another
410
443
  implementation reads them and nothing else.
411
444
 
412
445
  ## Status
@@ -103,6 +103,11 @@ $ backdraft search "24850000"
103
103
  [Read the page: backdraft read underwriting-model p2]
104
104
  ```
105
105
 
106
+ Search shows twenty results by default. A run that hit the limit says so —
107
+ `20 of 56 results` — and closes with the command that shows the rest, because
108
+ "20 results" over an answer of 56 is how a reader concludes it has seen
109
+ everything when it has seen a third.
110
+
106
111
  A cell you can already see mints directly:
107
112
 
108
113
  ```console
@@ -145,6 +150,7 @@ bound 17 claim(s), 18 citation(s) [frontwalk]
145
150
  resolved: 17
146
151
  unresolved: 1
147
152
  overlap: pass 13, skip 4
153
+ skip 4 — wording overlap does not apply to a single cell
148
154
  value-trace: pass 17
149
155
  ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @3629
150
156
  wrote .backdraft/records/memo.backdraft.json
@@ -168,6 +174,29 @@ memo.backdraft.html
168
174
  That file is the whole deliverable: send it over email or Slack and the
169
175
  recipient gets the full experience by double-clicking it.
170
176
 
177
+ **The recipient can check it.** `backdraft verify` takes the artifact or its
178
+ sidecar and does the checking that the file is built to make possible: every
179
+ snippet rehashed against the sha256 recorded beside it, every token checked
180
+ against the anchor it names, `summary` recounted from the claims.
181
+
182
+ ```console
183
+ $ backdraft verify memo.backdraft.html
184
+ checked memo.backdraft.html [backdraft/artifact-v1]
185
+ receipts: 17 of 17 hold
186
+ record: 17 claim(s), 18 citation(s); the summary recount agrees
187
+ recorded: resolved 17, unresolved 1
188
+ sources: no .backdraft/ found from here — not re-checked
189
+ [Re-check against the sources: run this inside the project it was bound in.]
190
+ ```
191
+
192
+ That first tier needs nothing but the file, which is the point — it is the check
193
+ someone who was emailed the artifact can still make. Run it inside the project
194
+ the document was bound in and it also re-resolves every citation against the
195
+ registry, so a source that has changed since shows up as `drifted` rather than
196
+ going unnoticed. Exit 0 when everything it checked passed, 2 when something did
197
+ not, so a hook can gate on it. It opens no session and mints nothing: an audit
198
+ must not make its subject citable.
199
+
171
200
  **Theming.** The artifact ships with a default look and two alternates,
172
201
  `press` and `slate`:
173
202
 
@@ -295,12 +324,13 @@ the keyless text layer and says so — still storing each page's image, which it
295
324
  renders locally. PDF page rendering needs poppler, installed separately; without
296
325
  it ingest succeeds without the images and names `snapshot-pages` as the backfill.
297
326
 
298
- Two extras:
327
+ Three extras:
299
328
 
300
329
  | Extra | Adds | For |
301
330
  |---|---|---|
302
331
  | `[entail]` | `anthropic` | `bind --check entail`, the model-judge verifier |
303
332
  | `[xls]` | `python-calamine` | legacy `.xls` workbooks, values only, rendered the same shape as xlsx |
333
+ | `[math]` | `latex2mathml` | LaTeX in a document becomes MathML in the artifact; without it, formulas render verbatim and `render` says so |
304
334
 
305
335
  `backdraft[vlm]` still installs: it is an empty compat alias from when the
306
336
  vision deps were an extra.
@@ -321,7 +351,7 @@ Three agent skills ship inside the package and in this repo:
321
351
  |---|---|
322
352
  | [`backdraft`](skills/backdraft/SKILL.md) | writing a new document from sources, citing as it goes |
323
353
  | [`backdraft-backfill`](skills/backdraft-backfill/SKILL.md) | attributing a document that already exists |
324
- | [`backdraft-artifact`](skills/backdraft-artifact/SKILL.md) | reading someone else's artifact cold |
354
+ | [`backdraft-artifact`](skills/backdraft-artifact/SKILL.md) | checking and reading someone else's artifact cold |
325
355
 
326
356
  The CLI is the system; a skill is one page telling an agent to use it. Nothing in
327
357
  the skills is required to use backdraft by hand. How the skills reach your agent
@@ -379,9 +409,10 @@ spans are citable. In a sandboxed session run every command as
379
409
  | [spec/tokens.md](spec/tokens.md) | the citation token grammar, normatively |
380
410
  | [spec/chunking.md](spec/chunking.md) | the deterministic chunker |
381
411
  | [spec/artifact.md](spec/artifact.md) | the artifact format, sidecar payload, legend, HTML rules |
412
+ | [spec/registry.md](spec/registry.md) | the registry export format `backdraft export` writes |
382
413
  | [demo/walkthrough.md](demo/walkthrough.md) | the whole thing end to end, with real output |
383
414
 
384
- The three files under `spec/` are the portable specification: another
415
+ The four files under `spec/` are the portable specification: another
385
416
  implementation reads them and nothing else.
386
417
 
387
418
  ## Status
@@ -0,0 +1,393 @@
1
+ # Roadmap
2
+
3
+ Sequenced intent. DESIGN.md records what was *decided*; this file records what
4
+ is *queued*, so tabling something is an act of writing it down here rather than
5
+ losing it. Items graduate by getting built and deleted.
6
+
7
+ ## How the queue runs
8
+
9
+ The Now section is an ordered queue written for an implementing agent with no
10
+ session context: each item carries its intent, its shape, and its acceptance
11
+ test. The working agreement is one item at a time, top first, shipped complete
12
+ — code, tests, docs, and a DESIGN.md decision row where the item calls for one
13
+ — then deleted from this file in the same commit. Releases cut Friday, so an
14
+ item that lands mid-week waits on PyPI until the release; nothing ships
15
+ half-done to make a release. An item that turns out bigger than its sizing
16
+ gets split here, not stretched silently.
17
+
18
+ The cadence is daily and the runner is an agent: one item each morning Monday
19
+ through Thursday, landing as a direct commit to `main` — this repo does not
20
+ use pull requests, so a week of commits is the only review surface there is.
21
+
22
+ Friday builds nothing new. It reads that week's diff instead — duplication,
23
+ drift from the repo's own idioms, branches that landed untested, help text and
24
+ error messages that confuse, commands the docs describe wrongly — and lands
25
+ the fixes. Then the release, and only if something landed since the last tag:
26
+ the version moves in both `pyproject.toml` and `.claude-plugin/plugin.json`,
27
+ and publishing the GitHub release is what ships to PyPI. Then this section is
28
+ refilled with five items for the week ahead, drawn against the core — the four
29
+ verbs, the registry, the grammar, the artifact — and against where an agent
30
+ using backdraft actually stumbles. What the maintenance pass just found is the
31
+ best evidence available for what those five should be.
32
+
33
+ ## Now
34
+
35
+ ### 1. `session show` says what the ledger holds
36
+
37
+ **Intent.** The ledger is the mechanism the design rests on — the set of citable
38
+ tokens is exactly the set the gate emitted — and nothing reads it back.
39
+ `backdraft session show` prints `session default (from default)` and stops, so
40
+ an agent cannot answer "have I read enough to write this yet?" except by binding
41
+ and reading the `not_shown` count, which means finding out after the draft
42
+ exists. Worse, the default session is documented as stable across invocations
43
+ *so reads accumulate*, which means an agent that never exported
44
+ `BACKDRAFT_SESSION` has its `not_shown` judged against everything ever read in
45
+ that registry by any previous run on any document — a real weakening of the
46
+ system's strongest check, currently invisible at every surface. `skills/
47
+ backdraft/SKILL.md` tells the agent to start a named session and calls it
48
+ optional; nothing shows it what optional costs.
49
+
50
+ **Shape.** `gate/cli.py`'s `session show` and `gate/reader.py` (the ledger is
51
+ the gate's, and `session start` already lives there). Report what the session
52
+ holds: how many anchors, across how many documents, and per document a slug and
53
+ a count — read off the `shown` rows the registry already keeps, which may need
54
+ one accessor on `Registry` beside `was_shown` (SPEC Addendum A, so state it
55
+ there). When the session is the default one, say plainly that it accumulates
56
+ across runs and that a named session is what makes `not_shown` mean "this
57
+ draft's author saw it" — the same voice the `--config` and poppler notes use, a
58
+ note at exit 0 and never a failure. An empty session says so and names
59
+ `backdraft read` rather than printing a bare zero.
60
+
61
+ **Acceptance.** After reading two pages of one document and searching another,
62
+ `backdraft session show` names both documents with their counts and a total; on
63
+ a fresh registry it says the session is empty and what to run. Running under
64
+ `BACKDRAFT_SESSION=s-x` shows `s-x` and no accumulation note; running with
65
+ nothing set shows the default and the note. `README.md`, `site/docs.html`,
66
+ `site/llms.txt` and `skills/backdraft/SKILL.md` point at it as the way to check
67
+ coverage before binding, and the skill's "optional" for `session start` becomes
68
+ a stated tradeoff.
69
+
70
+ **Size.** Two days.
71
+
72
+ ### 2. An unreadable source says what to do, not what errno it was
73
+
74
+ **Intent.** `ingest` now finishes its list and names each failure (2026-08-13),
75
+ which turned the reason string into something a calling agent actually reads —
76
+ and the reason is Python. A directory says `cannot read adir: [Errno 21] Is a
77
+ directory: 'adir'`; a locked file says `[Errno 13] Permission denied`; a missing
78
+ one says `[Errno 2] No such file or directory`. Each names the source three
79
+ times, leaks an errno an agent must not have to know, and — the real cost —
80
+ says nothing about the fix, though the fix differs completely between the three
81
+ and is obvious in each case. A directory means "name the files inside it, or
82
+ glob"; the repo's own rule is that an error says what went wrong *and* what to
83
+ do next, and this is the boundary where an unattended agent meets it most.
84
+
85
+ **Shape.** Wherever the read failure is raised on the ingest path — `cli.py`'s
86
+ `_staged`, `registry/store.py`'s ingest read, and `fetch.py` for the URL half —
87
+ turn the `OSError` into a `BackdraftError` whose message is written for the
88
+ caller: what the source is, why it could not be read, what to do. Map the causes
89
+ worth distinguishing (missing, a directory, unreadable, empty, and the fetch
90
+ side's own) and let anything unmapped keep the underlying text rather than
91
+ guessing — an unknown cause reported plainly beats a wrong suggestion. Say the
92
+ source once: the `!` line already leads with it, so the reason should not repeat
93
+ it. This is *not* the queued "Ingest says what it did and what it got" item, which is about what a successful ingest says
94
+ (no-op, new generation, character count); this is the failure half, and the two
95
+ should not be merged.
96
+
97
+ **Acceptance.** `backdraft ingest <a-directory>` names the directory once and
98
+ says to pass the files inside it or a glob; a missing file, an unreadable file
99
+ and an empty file each get their own reason and their own next step; none of the
100
+ four prints `[Errno`. A URL that 404s keeps saying so. The count line and the
101
+ closing re-run line are unchanged, and the clean-run stdout byte-identity test
102
+ from 2026-08-13 still passes. `site/llms.txt` says the failure reasons are
103
+ actionable so an agent surfaces them to the user rather than paraphrasing.
104
+
105
+ **Size.** One day.
106
+
107
+ ### 3. A registry can forget a source
108
+
109
+ **Intent.** Ingest is one-way. An agent working unattended over a folder — which
110
+ is now the normal case, since `ingest` finishes its list — will ingest something
111
+ it should not have: a scratch copy, a duplicate under two names, a file the user
112
+ did not mean to include. Nothing removes it. It stays in `backdraft read`'s list
113
+ forever, its anchors stay in `search` competing with the real source, and an
114
+ agent picking evidence can cite the wrong copy without anything looking wrong.
115
+ The only recovery is deleting `.backdraft/` and starting over, which throws away
116
+ every other document and the whole ledger.
117
+
118
+ **Shape.** Withdraw, do not delete — a new `backdraft forget <slug>` in `cli.py`
119
+ beside `ingest`, and the storage half in `registry/store.py`. A withdrawn
120
+ document disappears from `documents()`, from the gate's list and table of
121
+ contents, and from `search`; its anchors keep resolving, because a token already
122
+ written into somebody's draft or artifact must not silently become `unresolved`
123
+ — that is the failure mode this whole system exists to prevent, and the
124
+ 2026-08-05 "provenance, never identity" row governs. So `Registry.resolve` still
125
+ finds it and `show` and `bind` report it, with the closed status set gaining
126
+ nothing: reuse `unresolved` and let the *reason* say withdrawn, since adding a
127
+ status is an artifact-format change and this does not need one. Storage is a
128
+ flag on `documents`, not a DELETE, for the same reason. Require confirmation or
129
+ an explicit flag, since this is the one command that takes something away.
130
+ `spec/registry.md` has landed and pins the export against what it emits, so a
131
+ withdrawal flag reaching `documents[]` is a spec change: name the key there,
132
+ say what a reader must conclude from it, or keep it out of the export.
133
+
134
+ **Acceptance.** Ingest two documents, `forget` one: `read`, `ls` and `search`
135
+ show only the other; a token minted from the forgotten one still resolves
136
+ through `backdraft show`, saying it was withdrawn and when; `bind` on a document
137
+ citing it reports it and does not crash. `forget` on an unknown slug exits 1
138
+ naming the known ones. Re-ingesting the same file after forgetting it brings it
139
+ back as a new generation of the same document rather than a second slug — pin
140
+ that, it is the case that decides whether the flag was the right storage.
141
+ `README.md`, `site/docs.html`, `site/llms.txt` and `SPEC.md`'s command list gain
142
+ it, and it earns a DESIGN.md row: withdraw-not-delete, with the tradeoff that a
143
+ registry never actually shrinks.
144
+
145
+ **Size.** Three days.
146
+
147
+ ### 4. Bind's markdown names a fetched source by a file nobody has
148
+
149
+ **Intent.** The 2026-08-18 row made `gate.source_name` the one owner of what a
150
+ source is called and named the one surface it could not reach — bind, because
151
+ `bind` → `gate` is a sideways import SPEC forbids. The exception is larger than
152
+ that row said. `binder.py`'s `_doc_name` returns `document.filename`, and it
153
+ feeds both the generated `## References` section of `bind --bound` and the
154
+ proposals in the unmatched-claims section, so the markdown projection — the
155
+ form that travels into a pull request or an email, where the HTML artifact
156
+ cannot follow — calls the demo's Wikipedia article `index.html`. Bind the demo
157
+ with `--mode backfill --bound` and `memo.bound.md` says
158
+ `**[2]** index.html — p1.c11 — resolved` for a source that `render --to
159
+ footnotes` names `**franklin-county** · <https://en.wikipedia.org/…>` and the
160
+ artifact names `Franklin County`. Two of backdraft's own output formats
161
+ disagree about what a source is, and the one that disagrees is the one a reader
162
+ gets without a browser.
163
+
164
+ **Shape.** Answer the dependency objection rather than working around it.
165
+ `source_name` is a pure function of a `Document`, and `Document` lives in
166
+ `kernel/model.py`, which `gate` and `bind` both import *downward* from — so the
167
+ kernel is the owner the rule was always looking for, and it stays stdlib-pure
168
+ there. Decide and write down whether `gate.source_name` becomes a redirect or
169
+ whether callers move to the kernel path, remembering that kernel API in this
170
+ repo is module paths rather than flat re-exports, and that `cli.py` imports
171
+ `gate.source_name` today and SPEC § Gate names it. Leave the render side alone:
172
+ `render/html/text.source_title` and `render/footnotes._origin` read a `url` off
173
+ the *artifact's* `docs` dict, a different shape from a `Document`, and they are
174
+ already correct — say so in the row rather than letting the next reader think
175
+ they were missed.
176
+
177
+ **Acceptance.** Add an uncited claim to a copy of `demo/memo.md`, run `bind
178
+ memo.md --mode backfill --bound`, and `memo.bound.md` contains no `index.html`
179
+ and names the URL in both its References entries and its unmatched proposals.
180
+ A References entry for a *file* source is byte-identical to today's — pin it,
181
+ the way the file rows of the gate's list are pinned. `render --to footnotes` is
182
+ unchanged. SPEC's dependency rule and § Gate are updated, and the 2026-08-18
183
+ row gains its correction: the exception it accepted is now closed and the row
184
+ should say how.
185
+
186
+ **Size.** Two days.
187
+
188
+ ### 5. A web page has a name, and `read` shows it the navigation menu instead
189
+
190
+ **Intent.** `backdraft read franklin-county` — the demo's own web source —
191
+ prints `p1 Franklin County, Ohio - Wikipedia Jump to content Main menu Main
192
+ menu move to sidebar hide Navigation - Main page - Contents - Current events -
193
+ Random art...`. The table-of-contents line is the one surface that tells an
194
+ agent what is on a page before it spends context reading it, and for every web
195
+ source it is site chrome. The page's real name is sitting in the extracted text
196
+ and in the markup's `<title>`, unused: a fetched page ingested without `--slug`
197
+ also titles itself `En Wikipedia Org Index` in the artifact's source list,
198
+ because `render/html/text.source_title` falls back to the slug and title-cases
199
+ it. Two surfaces need one fact nothing captures. Separately, an HTML source is
200
+ a single page, so its table of contents is that one line and does no
201
+ navigating at all — for a 34,000-character article the citable unit is the
202
+ chunk and nothing lists the chunks.
203
+
204
+ **Shape.** Capture the fact once, at extraction: `extract/html.py` already
205
+ parses the markup, so the document's `<title>` becomes page or document meta
206
+ there, and everything downstream reads it instead of guessing. Then
207
+ `gate/reader.py`'s TOC preview (`TOC_PREVIEW_CHARS`, `_preview`) prefers it,
208
+ and `render/html/text.source_title` prefers it over the slug fallback for a
209
+ document that has one. For the single-page case, `render_toc` lists the page's
210
+ chunks with their opening words, so a one-page source's table of contents is
211
+ one. **This is display only and must stay that way**: no chunk boundary moves,
212
+ no anchor moves, no token changes, and the slug is not derived from the title —
213
+ that is the admissibility argument the 2026-08-20 thin-source note made, and
214
+ this row must make it again, because the neighbouring rule ("no boilerplate
215
+ stripping: a heuristic that changes its mind moves anchors") is about
216
+ extraction and is not being touched. A page with no `<title>`, and every
217
+ non-HTML source, keeps exactly what it prints today.
218
+
219
+ **Acceptance.** In `demo/`, `backdraft read franklin-county` names the county
220
+ rather than "Jump to content", and lists the page's chunks. `backdraft read
221
+ t12-summary` and `backdraft read underwriting-model` are byte-identical to
222
+ today's — pin both. `backdraft bind memo.md --session s-bridgeview --check
223
+ value-trace,overlap` still reports 17 resolved and the same one unresolved,
224
+ with the same tokens: mint them before and after and diff. An HTML fixture with
225
+ no `<title>` falls back to what it prints now. `demo/walkthrough.md`'s read
226
+ blocks and `README.md`'s show the real output. DESIGN row.
227
+
228
+ **Size.** Three days.
229
+
230
+ ### 6. A page read has no budget and no closing line
231
+
232
+ **Intent.** `backdraft read franklin-county p1` prints 36,442 characters and
233
+ stops, with nothing to say how much that was or whether it was all of it — it
234
+ ends mid-navigation on `49 languages Add topic`, which reads exactly like a
235
+ truncation and is not one. `--limit` defaults to "all of them", so the
236
+ continuation hint this repo is proud of — `[Showing 0-464 of 3115 chars.
237
+ Continue with: …]` — only ever appears to a caller who already knew to pass
238
+ `--limit`. The gate is the one surface an agent is *required* to use, which
239
+ makes the default the case that matters: one `read` can spend a large fraction
240
+ of a context window with no warning, and the agent cannot tell a complete page
241
+ from a cut one. `search` caps at 20 by default; `read` caps at nothing. This is
242
+ the same silent-cap family as the queued `search` item, from the other end — an
243
+ uncapped read that looks capped, rather than a capped search that looks
244
+ complete.
245
+
246
+ **Shape.** `gate/reader.py`'s page rendering and `gate/cli.py`'s `--limit`. The
247
+ decision to make and write down is a default budget versus staying unbounded
248
+ and always closing with the size and the continuation line; prefer whichever
249
+ keeps a small page byte-identical, since most pages are small and their output
250
+ is a contract. Whatever lands, a read that shows part of a page must close by
251
+ naming the exact command that continues it, as the existing hint does. One
252
+ constraint to state and test rather than discover: the window slices by
253
+ characters and a chunk token stands above its chunk, so a cut must not leave a
254
+ token over a chunk the reader only half received — either the window respects
255
+ chunk boundaries or the closing line says the last chunk is partial. Do not add
256
+ an offset story to `search` here; that is the queued item's ground.
257
+
258
+ **Acceptance.** `backdraft read t12-summary p1` in `demo/` is byte-identical to
259
+ what it prints today — pin it. `backdraft read franklin-county p1` closes by
260
+ saying how much of the page it showed and how to see the rest, and running the
261
+ command it names continues where it stopped. No read output ever shows a token
262
+ above a chunk it did not finish. `demo/walkthrough.md`, `README.md`,
263
+ `site/llms.txt` and `skills/backdraft/SKILL.md` show the real output and tell
264
+ the agent to continue rather than assume it saw the page. DESIGN row.
265
+
266
+ **Size.** Two days.
267
+
268
+ ### 7. The thin-source signal exists only in the ingest that printed it
269
+
270
+ **Intent.** 2026-08-20 gave `ingest` a character count and a `note: little text
271
+ extracted` naming the likely cause — the signal that a source is a shell and
272
+ must not be cited. Both are printed once and then gone: nothing stores the
273
+ count, and `ls`, the gate's document list and a document's table of contents
274
+ say nothing about it. The agent that writes is usually not the process that
275
+ ingested — the skill says to ingest everything up front and then read, and a
276
+ registry travels with the project folder — so the one signal that matters is
277
+ the one an agent is least likely to be present for. Off `backdraft read`'s
278
+ list, a 34,000-character article and a 60-character login wall are the same
279
+ row.
280
+
281
+ **Shape.** The count is derivable from the current extraction
282
+ (`sum(len(page.text))`), so this is a reporting change rather than a storage
283
+ one unless measurement says otherwise. One owner, shared between `cli.py`'s
284
+ ingest line and `gate/reader.py`'s list and headline, the way `unit` and
285
+ `source_name` already are — the point of this item is that three surfaces give
286
+ one answer, so a second implementation of the count would defeat it. Display
287
+ only, as the ingest note is: no token, anchor or status derives from it. Hold
288
+ the byte-identity rule the source-naming work established — a registry of
289
+ ordinary sources must not gain a column for this, so mark only the sources that
290
+ are thin rather than annotating every row.
291
+
292
+ **Acceptance.** Ingest a login-wall HTML file beside a real source: `backdraft
293
+ read` and `backdraft ls` mark the thin one and say nothing new about the other,
294
+ and `backdraft read <thin-slug>` says it in the headline too. A registry with
295
+ no thin source prints what it prints today, byte for byte — pinned, including
296
+ `demo/`'s three sources. `THIN_SOURCE_CHARS` is read from one place.
297
+ `skills/backdraft/SKILL.md` and `site/llms.txt` tell the agent that the
298
+ document list is where it learns this, not only the ingest it may not have run.
299
+
300
+ **Size.** Two days.
301
+
302
+ ### 8. What a URL will be called, before the answer is permanent
303
+
304
+ **Intent.** Three docs now tell an agent to pass `--slug` when it ingests a URL,
305
+ because a slug is permanent once tokens carry it and the default may name a
306
+ site rather than a page. None of them lets the agent find out what the default
307
+ would be. `fetch.filename_for` settles the answer from the address alone,
308
+ before any bytes exist — that is the 2026-08-19 row's own words — so the
309
+ question is cheap and answerable, and today the only way to ask it is to ingest
310
+ and live with the result. An agent that guesses wrong has written a name into
311
+ every token of the document, and the fix is re-ingesting under a new slug and
312
+ rewriting the draft.
313
+
314
+ **Shape.** Small and read-only. The natural home is beside the thing it
315
+ predicts: a flag on `ingest` that resolves each source to the slug and media
316
+ type it would take and prints them without fetching, writing or minting
317
+ anything, or a sibling command if a flag on a writing verb reads badly — pick
318
+ one and say why in the DESIGN row, since "a verb that does not do its verb" is
319
+ the objection to answer. For a URL the answer comes from `fetch.filename_for`
320
+ plus `registry.slug_for` and needs no network; the media type is the honest
321
+ gap, because only the served content type settles it, and the output must say
322
+ so rather than guessing. For a file, both are already knowable. Existing
323
+ collisions matter: a slug already taken must be reported as taken, since that is
324
+ what turns a predicted `index` into a real `index-2`.
325
+
326
+ **Acceptance.** Against `demo/`, asking about
327
+ `https://en.wikipedia.org/w/index.php?title=Franklin_County,_Ohio&oldid=1367935775`
328
+ prints `en-wikipedia-org-index` and does not create a document, open a socket,
329
+ or touch the ledger — check the registry's document count and `ls` before and
330
+ after. Asking about a path prints the slug the file would take. Asking about a
331
+ source whose slug is already in the registry says so. `README.md`,
332
+ `site/docs.html`, `site/llms.txt` and `skills/backdraft/SKILL.md` name it where
333
+ they currently say "pass `--slug`", so the advice comes with the way to check
334
+ it.
335
+
336
+ **Size.** One day.
337
+
338
+ ## Parked
339
+
340
+ Deliberately not queued, each with the reason, so picking one up starts from
341
+ the objection rather than rediscovering it.
342
+
343
+ - **Exhibits view** — the evidence-first inversion. Risk: it grows the
344
+ artifact and pollutes a design whose strength is restraint. Needs a
345
+ deliberate design pass and a real audience before any code; not a
346
+ weekend-feature shape.
347
+ - **Excel region maps and range reads** — the research notes exist, but no
348
+ confidence the deterministic detection cascade lands well on the first
349
+ try. Revisit when large-sheet navigation hurts in a real corpus, with
350
+ test workbooks in hand.
351
+ - **`bd:calc` derivations** — reserved in the grammar and parsed as
352
+ malformed-on-purpose, which is enough for now. Full support complicates
353
+ binding and verification for one field-trial case.
354
+ - **Merged-cell and frozen-pane rendering** — ingest captures `merged` and
355
+ `frozen` in sheet meta and keeps them (cheap, and capture is the part you
356
+ cannot backfill). Frozen-pane rendering is cut; merged-cell rendering
357
+ waits for a real sheet that renders wrong without it.
358
+
359
+ ## Later
360
+
361
+ - **Living documents** — re-ingest, re-bind, and a drift-first report: "these
362
+ claims cite figures that changed; cited-then vs. now." The primitives
363
+ (generations, `drifted`, the word-diff) all exist; the missing piece is a
364
+ diff-shaped report and a demo.
365
+ - **Substrates beyond the CLI** — SDK middleware for pipelines (the exit-code
366
+ and `--to json` contracts are already shaped for it), and a client-side
367
+ drag-drop viewer page (no upload — the file never leaves the reader's
368
+ machine).
369
+ - **Email (.eml)** — arrives in diligence folders alongside everything else;
370
+ identity questions (headers vs. body, attachments as child sources) deserve
371
+ a decision row before code. The web-page half of this item shipped
372
+ 2026-08-05; `document_meta` is where an email's provenance would land.
373
+ - **Anthropic-API provider for the VLM extractor** — Cowork's sandbox
374
+ egress allowlist includes api.anthropic.com but not the OpenAI-compatible
375
+ providers, so an Anthropic-native option would let sandboxed sessions
376
+ ingest at full fidelity. Needs a client abstraction the vlm module
377
+ currently doesn't have; the documented workaround (ingest locally, the
378
+ registry travels with the folder) covers it meanwhile.
379
+ - **Distribution** — published on PyPI; the repo ships
380
+ `.claude-plugin/plugin.json` and `marketplace.json`, so it installs as a
381
+ Claude Code plugin and self-hosts its marketplace, and the AGENTS.md
382
+ snippet is published in the README and site docs. The community-directory
383
+ submission went in 2026-07-29 and is awaiting review — approval is what
384
+ surfaces the skills in Cowork's directory.
385
+
386
+ ## Someday
387
+
388
+ - **Hosted team features** — upload, access control, shared registries.
389
+ Deliberately parked: the product's trust story is the self-contained file,
390
+ and a hosted viewer inverts it. Revisit only as a distinct teams product.
391
+ - **Entail at scale** — the model-judge verifier is wired (`[entail]` extra)
392
+ but has never been field-calibrated; needs a corpus and a rubric before it
393
+ is recommended anywhere.