backdraft 0.12.0__tar.gz → 0.14.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 (178) hide show
  1. {backdraft-0.12.0 → backdraft-0.14.0}/.claude-plugin/plugin.json +1 -1
  2. {backdraft-0.12.0 → backdraft-0.14.0}/DESIGN.md +7 -0
  3. {backdraft-0.12.0 → backdraft-0.14.0}/PKG-INFO +54 -10
  4. {backdraft-0.12.0 → backdraft-0.14.0}/README.md +53 -9
  5. backdraft-0.14.0/ROADMAP.md +574 -0
  6. {backdraft-0.12.0 → backdraft-0.14.0}/SPEC.md +16 -6
  7. {backdraft-0.12.0 → backdraft-0.14.0}/demo/memo.backdraft.html +2 -2
  8. {backdraft-0.12.0 → backdraft-0.14.0}/demo/walkthrough.md +32 -7
  9. {backdraft-0.12.0 → backdraft-0.14.0}/pyproject.toml +1 -1
  10. {backdraft-0.12.0 → backdraft-0.14.0}/site/demo.html +2 -2
  11. {backdraft-0.12.0 → backdraft-0.14.0}/site/docs.html +23 -9
  12. {backdraft-0.12.0 → backdraft-0.14.0}/site/llms.txt +47 -12
  13. {backdraft-0.12.0 → backdraft-0.14.0}/skills/backdraft/SKILL.md +51 -13
  14. {backdraft-0.12.0 → backdraft-0.14.0}/skills/backdraft-artifact/SKILL.md +16 -4
  15. {backdraft-0.12.0 → backdraft-0.14.0}/skills/backdraft-backfill/SKILL.md +3 -1
  16. {backdraft-0.12.0 → backdraft-0.14.0}/spec/artifact.md +28 -8
  17. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/bind/binder.py +7 -15
  18. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/bind/cli.py +41 -7
  19. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/bind/verify/entail.py +27 -10
  20. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/cli.py +280 -38
  21. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/cli_context.py +90 -3
  22. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/__init__.py +2 -0
  23. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/base.py +23 -4
  24. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/snapshots.py +43 -4
  25. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/gate/cli.py +6 -2
  26. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/gate/reader.py +158 -15
  27. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/gate/searcher.py +1 -1
  28. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/kernel/artifact.py +30 -1
  29. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/registry/__init__.py +6 -0
  30. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/registry/ledger.py +15 -0
  31. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/registry/store.py +122 -20
  32. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/cli.py +135 -18
  33. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/math.py +5 -1
  34. {backdraft-0.12.0 → backdraft-0.14.0}/tests/gate/fake_registry.py +7 -2
  35. {backdraft-0.12.0 → backdraft-0.14.0}/tests/gate/test_gate_cli.py +6 -0
  36. {backdraft-0.12.0 → backdraft-0.14.0}/tests/gate/test_gate_reader.py +130 -0
  37. {backdraft-0.12.0 → backdraft-0.14.0}/tests/gate/test_gate_searcher.py +22 -1
  38. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/render/backfill.sidecar.json +1 -1
  39. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/render/demo.sidecar.json +1 -1
  40. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_bind_cli.py +179 -1
  41. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_cli.py +133 -0
  42. backdraft-0.14.0/tests/test_doctor.py +321 -0
  43. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_extract.py +1 -1
  44. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_forget.py +87 -1
  45. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_ingest_url.py +14 -0
  46. backdraft-0.14.0/tests/test_next_moves.py +75 -0
  47. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_registry.py +40 -0
  48. backdraft-0.14.0/tests/test_search_window.py +135 -0
  49. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_verify_cli.py +314 -2
  50. {backdraft-0.12.0 → backdraft-0.14.0}/uv.lock +1 -1
  51. backdraft-0.12.0/ROADMAP.md +0 -554
  52. {backdraft-0.12.0 → backdraft-0.14.0}/.claude-plugin/marketplace.json +0 -0
  53. {backdraft-0.12.0 → backdraft-0.14.0}/.github/workflows/ci.yml +0 -0
  54. {backdraft-0.12.0 → backdraft-0.14.0}/.github/workflows/release.yml +0 -0
  55. {backdraft-0.12.0 → backdraft-0.14.0}/.github/workflows/site.yml +0 -0
  56. {backdraft-0.12.0 → backdraft-0.14.0}/.gitignore +0 -0
  57. {backdraft-0.12.0 → backdraft-0.14.0}/.python-version +0 -0
  58. {backdraft-0.12.0 → backdraft-0.14.0}/LICENSE +0 -0
  59. {backdraft-0.12.0 → backdraft-0.14.0}/assets/backdraft-mark.svg +0 -0
  60. {backdraft-0.12.0 → backdraft-0.14.0}/demo/generate_sources.py +0 -0
  61. {backdraft-0.12.0 → backdraft-0.14.0}/demo/memo.md +0 -0
  62. {backdraft-0.12.0 → backdraft-0.14.0}/demo/sources/t12-summary.pdf +0 -0
  63. {backdraft-0.12.0 → backdraft-0.14.0}/demo/sources/underwriting-model.xlsx +0 -0
  64. {backdraft-0.12.0 → backdraft-0.14.0}/site/index.html +0 -0
  65. {backdraft-0.12.0 → backdraft-0.14.0}/site/privacy.html +0 -0
  66. {backdraft-0.12.0 → backdraft-0.14.0}/spec/chunking.md +0 -0
  67. {backdraft-0.12.0 → backdraft-0.14.0}/spec/registry.md +0 -0
  68. {backdraft-0.12.0 → backdraft-0.14.0}/spec/tokens.md +0 -0
  69. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/__init__.py +0 -0
  70. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/__main__.py +0 -0
  71. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/bind/__init__.py +0 -0
  72. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/bind/evidence.py +0 -0
  73. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/bind/verify/__init__.py +0 -0
  74. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/bind/verify/base.py +0 -0
  75. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/bind/verify/overlap.py +0 -0
  76. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/bind/verify/recompute.py +0 -0
  77. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/bind/verify/value_trace.py +0 -0
  78. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/credentials.py +0 -0
  79. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/csv.py +0 -0
  80. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/docx.py +0 -0
  81. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/html.py +0 -0
  82. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/image.py +0 -0
  83. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/pdf_text.py +0 -0
  84. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/pptx.py +0 -0
  85. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/sheet.py +0 -0
  86. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/text.py +0 -0
  87. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/vlm.py +0 -0
  88. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/vlm_settings.py +0 -0
  89. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/xls.py +0 -0
  90. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/extract/xlsx.py +0 -0
  91. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/fetch.py +0 -0
  92. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/gate/__init__.py +0 -0
  93. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/kernel/__init__.py +0 -0
  94. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/kernel/chunking.py +0 -0
  95. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/kernel/claims.py +0 -0
  96. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/kernel/errors.py +0 -0
  97. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/kernel/hashing.py +0 -0
  98. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/kernel/model.py +0 -0
  99. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/kernel/tokens.py +0 -0
  100. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/registry/schema.sql +0 -0
  101. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/__init__.py +0 -0
  102. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/_text.py +0 -0
  103. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/footnotes.py +0 -0
  104. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/html/__init__.py +0 -0
  105. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/html/assets.py +0 -0
  106. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/html/components.py +0 -0
  107. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/html/fmt.py +0 -0
  108. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/html/fmt_vectors.py +0 -0
  109. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/html/page.py +0 -0
  110. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/html/text.py +0 -0
  111. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/markdown.py +0 -0
  112. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/placement.py +0 -0
  113. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/sidecar.py +0 -0
  114. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/theme.py +0 -0
  115. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/themes/default.toml +0 -0
  116. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/themes/press.toml +0 -0
  117. {backdraft-0.12.0 → backdraft-0.14.0}/src/backdraft/render/themes/slate.toml +0 -0
  118. {backdraft-0.12.0 → backdraft-0.14.0}/tests/conftest.py +0 -0
  119. {backdraft-0.12.0 → backdraft-0.14.0}/tests/conftest_registry.py +0 -0
  120. {backdraft-0.12.0 → backdraft-0.14.0}/tests/conftest_render.py +0 -0
  121. {backdraft-0.12.0 → backdraft-0.14.0}/tests/continuation_util.py +0 -0
  122. {backdraft-0.12.0 → backdraft-0.14.0}/tests/css_util.py +0 -0
  123. {backdraft-0.12.0 → backdraft-0.14.0}/tests/fakes.py +0 -0
  124. {backdraft-0.12.0 → backdraft-0.14.0}/tests/gate/conftest.py +0 -0
  125. {backdraft-0.12.0 → backdraft-0.14.0}/tests/gate/test_gate_adjoining.py +0 -0
  126. {backdraft-0.12.0 → backdraft-0.14.0}/tests/gate/test_gate_ledger.py +0 -0
  127. {backdraft-0.12.0 → backdraft-0.14.0}/tests/gate/test_reader_cells.py +0 -0
  128. {backdraft-0.12.0 → backdraft-0.14.0}/tests/gate/test_reader_show.py +0 -0
  129. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/bind/backfill.bound.md +0 -0
  130. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/bind/frontwalk.bound.md +0 -0
  131. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/chunking/long_tail.json +0 -0
  132. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/chunking/long_tail.txt +0 -0
  133. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/chunking/many_small.json +0 -0
  134. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/chunking/many_small.txt +0 -0
  135. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/chunking/mixed.json +0 -0
  136. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/chunking/mixed.txt +0 -0
  137. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/chunking/one_giant.json +0 -0
  138. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/chunking/one_giant.txt +0 -0
  139. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/chunking/short_page.json +0 -0
  140. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/chunking/short_page.txt +0 -0
  141. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/chunking/unsplittable.json +0 -0
  142. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/chunking/unsplittable.txt +0 -0
  143. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/render/backfill.footnotes.md +0 -0
  144. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden/render/demo.footnotes.md +0 -0
  145. {backdraft-0.12.0 → backdraft-0.14.0}/tests/golden_util.py +0 -0
  146. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_bind_binder.py +0 -0
  147. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_bind_evidence.py +0 -0
  148. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_bind_verify.py +0 -0
  149. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_card_sizing.py +0 -0
  150. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_chunking.py +0 -0
  151. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_claims.py +0 -0
  152. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_credentials.py +0 -0
  153. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_docs.py +0 -0
  154. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_dry_run.py +0 -0
  155. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_extract_html.py +0 -0
  156. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_fetch.py +0 -0
  157. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_fmt_parity.py +0 -0
  158. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_footnotes.py +0 -0
  159. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_gate_integration.py +0 -0
  160. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_hashing.py +0 -0
  161. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_html.py +0 -0
  162. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_invariants.py +0 -0
  163. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_locate.py +0 -0
  164. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_model.py +0 -0
  165. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_placement.py +0 -0
  166. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_records_location.py +0 -0
  167. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_reingest.py +0 -0
  168. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_render_cli.py +0 -0
  169. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_render_markdown.py +0 -0
  170. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_render_math.py +0 -0
  171. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_sidecar.py +0 -0
  172. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_snapshot_settings.py +0 -0
  173. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_snapshots.py +0 -0
  174. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_spec_registry.py +0 -0
  175. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_theme.py +0 -0
  176. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_tokens.py +0 -0
  177. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_value_trace.py +0 -0
  178. {backdraft-0.12.0 → backdraft-0.14.0}/tests/test_version.py +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "backdraft",
3
3
  "displayName": "Backdraft",
4
- "version": "0.12.0",
4
+ "version": "0.14.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",
@@ -163,6 +163,13 @@ Self-contained HTML (plus the machine-readable sidecar): **quotes + receipts + e
163
163
  | 2026-09-15 | **`verify --against <project>` checks a file you were sent against a registry you have, and discovery still infers nothing.** The 2026-08-24 row put `verify`'s source tier behind discovery from cwd, because an artifact is a file people forward and the folder it landed in says nothing about which registry produced it. That rule is kept exactly, and it had left no way to say what the recipient often does know — this file came out of *that* project — so the only route to a source check was to copy the artifact into the project first: a filesystem move performed to change a discovery result, repeated per artifact by an agent auditing several, and impossible for a reviewer without write access to the checkout. The flag answers the objection rather than overriding it. Discovery stays refusal-by-default and never looks at the artifact's location; `--against` is the caller asserting a link the tool must not infer, the same shape as `--slug` overruling a derived name — the tool derives nothing it is not told, and what it is told it takes as said. Three consequences follow from treating it as an assertion rather than a hint. It bypasses discovery instead of joining it, so it outranks `BACKDRAFT_HOME` as well as the walk: a name typed on one command is more specific than one exported for a whole shell, and a test pins flag, then environment, then walk. A path holding no registry is a usage error (exit 1) naming what was expected, never a fall back to tier one — a caller who named a registry and got the file-only check would read `sources: no .backdraft/ found from here` as a fact about the file rather than a typo — and it is refused *before* anything opens, because `Registry.open` creates what it does not find and a read-only audit must not leave a registry in whatever directory was typed. And the report names the registry that answered either way, on the `sources:` line and in `--json`'s `sources.registry`, resolved to an absolute root so the line from an inbox is the line from inside the project, which is what makes a wrong `--against` visible rather than just a run of `unresolved`. Both forms `BACKDRAFT_HOME` accepts — the project root or the `.backdraft` directory — are accepted through one rule (`cli_context.named_root` shares `find_root`'s), so the two ways of naming a registry cannot drift. Without the flag nothing moved: seven scenarios, in and out of the demo project, with and without `--json` and `BACKDRAFT_HOME`, diff byte-identical against the previous CLI, including the closing `[Re-check against the sources: …]` hint, which still names only the in-project route; pointing that hint at the flag is a wording change the acceptance ruled out of this item. |
164
164
  | 2026-09-16 | **A chunk whose text may run on names the chunk it runs on into, decided by the chunks' own edges and never by the prose — so every page break counts.** The writing skill told an agent that "a claim that spans two chunks needs both tokens, not the nearest one", which is right and gave it nothing to act on: the gate handed back chunks, and whether the sentence being cited ended inside one was for the agent to notice by eye, mid-draft. When it did not, nothing complained — one token resolved, the receipt was real, the artifact rendered clean, and the half of the claim in the next chunk was uncited while looking cited, the one failure worse than `unresolved`, because `unresolved` says so. `gate.reader.adjoining` now answers it off the registry's own rows. On one page, two chunks adjoin when the whitespace between their stored offsets holds no blank line, tested with the chunker's own separator — `kernel.chunking.PARAGRAPH_BREAK`, made public so the question is asked of one pattern rather than a copy — because the chunker splits on blank lines before anything else, so two chunks with none between them are a paragraph rule 3 cut for length. A search hit carries that neighbour indented under its excerpt, labelled `same paragraph, before` or `after`, over the edge that touches the hit: the tail of the chunk before, the head of the chunk after. The page break was the decision, and it went the uncomfortable way on purpose. Each page is chunked alone and `pdf-text` writes no whitespace at a page's edges, so nothing a chunk carries tells a paragraph that crosses the break from one that ended at it; the only way to tell would be a sentence test at the join, and the obvious one — rule 3's own terminal-punctuation definition — was rejected twice over. It would decide what gets minted, which a guess must not. And its miss is exactly this row's failure: a paragraph that crosses the break at a full stop, under a claim spanning those two sentences. So a page break always adjoins, labelled `previous page ends` or `next page begins` rather than `same paragraph`, since that is all that is known, and the excerpt shows the reader in one line which case it is. The page read takes the same rule where it adds something: every chunk inside a read already sits beside the ones it adjoins, so only a read that shows the last chunk of its last page names anything — the next page's first chunk — and a read that stopped short already names the command that goes on. The costs, named rather than discovered. Emitting is minting, so the adjoining chunk is recorded as shown though only an excerpt of it was: it now binds `resolved` for a claim drawn from text the writer saw 160 characters of, the trade `search` already makes for every hit, and `ADJOINING_NOTE` says so once per run. It is noisy on clean documents: a read of any page of a paginated source but the last gains three lines and the note, and a hit on a page's first or last chunk gains its neighbour. The demo's own `read t12-summary p1`, a page ending on a full stop before a heading, now names `p2.c1`, which moves the walkthrough's `session show` from 77 anchors to 78; the gate tests' toy fixtures, one sentence a page, show it hardest. Display and ledger only everywhere else: no chunk boundary, token, anchor or receipt moves, and a hit or a read with nothing adjoining prints byte for byte what it did — pinned over the fakes, which now carry offsets the way the store does, and over a real ingest of a PDF whose paragraph the typesetter carried onto the next page. `EXCERPT_CHARS` and the excerpt cut moved from `searcher.py` to `reader.py` for the reason `LIST_HINT` lives there: both surfaces print the neighbour in one shape, and the searcher imports the reader, never the reverse. |
165
165
  | 2026-09-17 | **A drifted citation whose words did not change is found where they stand now, by exact text and never by resemblance, and printing its new token is not minting it.** A re-ingested source re-chunks, so a paragraph inserted near the top of a page moves every chunk ordinal below it, and a citation whose words are untouched comes back `drifted` because a chunk's ordinal is its address — the Open list's oldest line, sharpened when `ingest` learned to announce a new generation (2026-08-20) and still left the agent a list of failures and a manual hunt per line. `backdraft locate <doc.md>` does the hunt: for each citation `citation_for` calls `drifted`, it looks the cited anchor's snippet hash up in the document's current generation (`Registry.anchors_with_snippet`, one query on the new `idx_anchors_snippet`, which the idempotent DDL adds to an existing registry on its next open) through `current_with`, `current_at`'s other half — that one keeps the locator and asks what text stands there, this keeps the text and asks which locators hold it. Matching is the normalized hash, same anchor kind, and nothing looser, because a near match proposed as a move rewrites provenance; the price is named rather than discovered — a sentence edited by one figure is `gone`, and so is a chunk that an insertion under 200 characters merged into (chunking rule 2), since its text did change. Three outcomes, and only one proposes: `moved` is exactly one place holding the words of a chunk or a page. Several places are `ambiguous` and named, because choosing among identical text is a guess. So is a cell with one match, which was the real decision: a cell's receipt is its value, and an equal value at another address is as often a second figure that agrees — two rates of 6.25% — as the same cell after a row went in, so a workbook gets lines naming the right cell for a person to confirm rather than tokens to paste. Read-only is the queued shape and the gate's doctrine argued against it, since every gate surface that hands a writer a token to cite mints it; it holds anyway, and the reason is that printing is not what makes a token citable — `bind` is. A moved token binds `not_shown` until the session is shown it, which is the ledger being right about names even where the words were seen, so the queued acceptance ("applying those tokens by hand and re-binding gives a clean run") holds one step later than written: the closing line names the `show` that records exactly the moved tokens `--session` has not seen (read, never created, and carried when typed through `gate.session_argument`), or the re-bind itself once none remain. Minting here was the alternative and was rejected, because a command that proposes should not write the ledger it proposes against, and `verify` already prints tokens without minting them. Two more departures from the queued shape, stated. It walks the document's own tokens (`kernel.claims.parse_claims`) rather than the stored `bindings`, which go stale the moment one token is swapped by hand and key on the path as it was typed. And it lives in `cli.py` beside the `ingest` whose note now names it, not in `bind/cli.py`, since that closing line needs the gate's session wording and bind may not import the gate. Exit 0 whatever it finds: `bind` is the check and this is the advice. Not Later's living documents, which is a cited-then-versus-now presentation; this is the lookup that presentation would stand on. |
166
+ | 2026-10-06 | **`bind` names the session it judged `not_shown` against, and a record bound in the default one says what that costs wherever it travels.** The 2026-08-31 row found that the default session accumulates every read any run in a registry ever made and fixed it at `session show`, the command that reports the ledger, leaving `bind`, the command that acts on it, to resolve the session in silence: `backdraft bind memo.md` with nothing exported judged against the shared ledger and its report never said so — in this repo's own demo that run binds clean with no `not_shown` at all, because development runs have read everything into `default`, which is exactly the false comfort the row described. The front-walk header now ends `against session <id>` and, where the id is `default`, the report closes with `gate.reader.DEFAULT_SESSION_NOTE` itself, imported rather than re-worded, since two wordings of one cost is how the two commands' warnings would drift; keyed on the id as `session show` keys it, so an explicit `--session default` is the same run byte for byte. Backfill names no session and gets no note, because it never consults the ledger and a warning that `bind` judges `not_shown` against all of it would be false there. The CLI does not travel and the record does, so the format says it too: `not_shown`'s legend entry and `spec/artifact.md`'s `session_id` row state that `default` means a ledger every run shares, and `verify`'s first tier prints a `session: default` line from the file alone, with no registry, so the recipient — the reader least able to learn it otherwise — is told. That sentence is `kernel.artifact.DEFAULT_SESSION_MEANING`, one string the legend embeds and `verify` prints, and the id itself moved into the kernel as `DEFAULT_SESSION` (re-exported by `cli_context` unchanged) because a session id the format assigns meaning to is format, not CLI vocabulary. The writer's note and the reader's sentence stay two strings on purpose: one says what to do before binding, the other what to conclude from a record already bound, and neither audience can act on the other's. The cost is the 2026-08-24 trade taken again — a legend change regenerates the golden sidecars and the demo artifact — and `verify --json` gained nothing, since the record it checks already carries `session_id` and the line is a reading of the record, not a finding. |
167
+ | 2026-10-07 | **What `forget` took out is listed by `ls --withdrawn`, a flag rather than a block under `ls`.** Withdrawal took a source off every surface that offers one, which is right, and left none that lists what went: `ls` in a registry holding two withdrawn documents said `no documents ingested`, and the only places a withdrawn document still appeared — `session show`'s mark, the JSON export, the unknown-slug error — are not where anybody looks. That mattered most for the undo, since re-ingesting is the way back and the path to re-ingest lives on the document, which is exactly what drops out of the list. The choice was a flag or a block printed under `ls` whenever the registry holds a withdrawal, and the block lost on the objection the item named: `ls` is the readable set, and anything that widens it risks making a withdrawn source look available — a footer under the live rows is one scroll from being read as more of them, and an agent parsing tab rows would have to learn to stop at it. So `ls --withdrawn` is a view of its own that lists only the withdrawn documents, never a marked row sorted among live ones, and default `ls` holds the byte-identity rule the source-naming work set: a registry with a withdrawal beside live sources prints exactly what the live sources alone print. The one place default `ls` does change is the place it was false: with nothing on offer and something withdrawn it says `no documents on offer: N withdrawn` and names the flag, instead of claiming an empty registry. Each withdrawn row is `ls`'s four fields then `withdrawn_reason` and `WITHDRAWN_HINT` verbatim, so the date and the way back keep their single owners rather than gaining a fourth wording, and it drops the `little text` mark, which advises a citation and nothing here is on offer to cite. The gate's own document list is untouched and pinned equal: the gate serves sources, and a withdrawn source is not one. The cost is discoverability — a flag is found by reading `--help` or the docs, not by running `ls` — which the `unresolved — withdrawn` guidance in the skill and `llms.txt` now pays by naming it at the moment a caller meets a withdrawal. |
168
+ | 2026-10-08 | **`ingest` says when `--slug` was not applied, and names the cost of a rename rather than a route to one, because there is no route.** `Registry.ingest` honours a slug only for a new document, a rule stated in its docstring and nowhere a caller reads: `backdraft ingest report.pdf --slug q4-report` against a file already ingested printed the old slug at exit 0 and never mentioned the flag, which is worst on a URL re-fetch, where the host-built fallback slug is exactly what a late `--slug` is trying to replace. The source's line now ends `--slug not applied` and one note names the slug the document keeps, in the shape `restored` set — a mark beside the outcome and a grouped note, not a third reporting shape — with no registry or schema change, since the CLI holds the requested slug and the returned `Ingested` carries the assigned one. The item as queued asked the note to offer `forget` and a re-ingest under the new slug as the costly way to re-slug; that path does not exist, because `_find_document` matches a withdrawn document by bytes and then origin and never by slug, so the re-ingest restores the same document under the same slug — the 2026-09-02 rule that makes re-ingest `forget`'s undo. Printing it would have been advice that cannot be taken, so the note says a rename would strand every token carrying the slug and that nothing renames a document, `forget` included, and a test pins that the forget-then-ingest run reports `restored --slug not applied`. Exit 0 throughout: getting back the document you already had is not a failure. A `--slug` on a new document and every ingest without one are byte-identical, pinned and checked against `demo/`'s sources. The dry run's own advice for a source not yet ingested — that changing a slug later means re-ingesting — was untouched here and was then the one surface that still implied a re-ingest renames; the release cleanup gave it this note's wording. |
169
+ | 2026-10-12 | **`verify` re-checks `not_shown` against the ledger session the record names, reading it and never creating it.** The source tier claimed to report statuses "as `bind` would" with one status it could not reach, so a record saying a claim cited something its author never read came back `resolved` from the tier meant to be the stronger check — and the claim this product rests on, that the citable set is exactly the set the gate emitted, was the one a recipient holding the registry could not re-make. The standing objection was the docstring's: verify is read-only, opens no session and mints nothing, which is what separates it from `show`. It is answered rather than overridden by asking only what the record names: the session is the record's `session_id`, never one verify resolves from flags or the environment, and it is found with a new `Registry.has_session` instead of `ensure_session`, because a session id this registry has never seen is a fact about where the record came from and creating it would both widen the ledger and turn "absent" into "empty", which reads as every citation `not_shown`. Absent, `null` and backfill each leave `not_shown` unchecked and say which on a `ledger:` line; a ledger that agrees with every citation prints no line, so every report it stands behind is byte-identical to before, `demo/`'s included. The `was_shown` rule moved into `registry.citation_for` behind an optional `session_id` rather than being restated beside bind's copy — the walk already lived there so the two commands could not drift, and the one status left outside it was the one that just gained a second caller. A `not_shown` today is a `source` finding and exits 2 like a kept `unresolved`, since both are citations the sources and the ledger do not stand behind; the ledger is read as it stands, so a session that has read the passage since binding verifies clean. The cost is `verify --json`: `sources` gains a `ledger` key (`read`, `absent`, `no_session`, `backfill`, or `null` when the tier did not run), additive under verify-v1's unknown-keys rule but not byte-identical, taken because a caller branching on `findings` otherwise could not tell a `resolved` the ledger confirmed from one it was never asked about — the plain report can say that, and one set of findings with two presenters (2026-09-14) means the object must too. |
170
+ | 2026-10-13 | **`bind` and `verify` close with one `next:` line per kind of failure they printed, naming the command that addresses it, from one table both import.** The 2026-09-01 row made `ingest`'s failures say the move as well as the diagnosis; the two commands whose output is the product still said only the diagnosis, and the move differs sharply per status — `show` clears a `not_shown`, `search` (or an honest uncited claim) an `unresolved`, `locate` a `drifted`, a syntax fix a `malformed`, and a `receipt` finding means the file was edited, the opposite of every source finding — so it lived only in the skill, where a hook's log, a CI job or a person never sees it. Grouped per kind rather than appended to each line item, because one move answers every item of its kind and eighteen identical hints would bury the items. `cli_context.NEXT_MOVES` is the one owner, keyed by the closed `CitationStatus` set plus `unmatched`, `receipt` and `recount`, and a test holds it total and holds every command it names to one `backdraft --help` lists; the lines carry the document and session the run used, shell-quoted, so the command is the one to type. Display only: no exit code, status or record field moves, `--json` carries none of it (2026-09-14 serves the caller that parses, this the one that relays), and a clean run's report is byte-identical. The tradeoff is a long line on a failing run and a second wording of the skill's per-status guidance; the skill keeps the nuance (withdrawn sources, `ambiguous` and `gone` from `locate`) and the line keeps only the command. In `bind` the lines sit above the default-session note, which keeps the last word it already had. |
171
+ | 2026-10-15 | **A search hit's excerpt opens on what FTS5 matched when the chunk's opening does not show it, and the registry, not the gate, says what matched.** `search` printed every hit's first 160 characters, on purpose: `excerpt`'s NOTE said FTS5 decides what matched — stemming, a phrase run as one — and the gate re-derives nothing the registry owns. The cost was that a long chunk's excerpt was its opening rather than its evidence; the walkthrough's own `search "replacement reserve"` returned a hit whose 160 characters never contained the phrase, and an agent picking among twenty hits does not `show` each one. The NOTE is answered rather than overridden: `Registry.search` asks FTS5 itself, through `highlight()` with two control-character markers no extracted snippet carries, and `SearchHit` gains `matches` — the marked spans as offsets into the receipt snippet, checked back against that snippet and dropped whole if the marked text disagrees, since no span is better than a wrong one. `highlight()` over `snippet()` because `snippet()` cuts by FTS5 tokens and writes its own ellipses, which would put a second length rule beside `EXCERPT_CHARS`; the offsets leave the cut where it was and hand the gate only the fact it could not know. The gate decides where to cut and nothing else: a hit whose first match ends inside the first `EXCERPT_CHARS` prints exactly what it did, so every documented `search` block over cells and short chunks is byte-identical and only the excerpts that hid their evidence move; otherwise the window opens `MATCH_LEAD_CHARS` ahead of whichever match lets it hold the most matches whole, snapped to a word, kept inside the text, and elided at whichever end it cut. The spans travel through `normalize` as markers rather than as offset arithmetic, so the whitespace collapse and NFC move them with the text, and both ends read markers through the one `registry.unmark`, so the registry and the gate cannot disagree about where a span ends. A phrase-fallback retry needs nothing extra — `highlight()` marks the phrase that actually ran. `matches` defaults to empty, so Addendum A's three-field `SearchHit` still constructs and a registry that reports nothing renders the old cut. Display only: no token, anchor, receipt, ledger row or `registry-v1` field changes, and the FTS index stays outside the export. The cost, named: a windowed excerpt can begin mid-sentence, and a multi-term query's densest cluster is not always the sentence the caller meant — the token is still the whole chunk and `show` still prints it. |
172
+ | 2026-10-15 | **A sheet is thin when no cell holds a value, not when its text is short.** The 2026-08-20 threshold is a fair proxy for prose — a login wall and a scan both extract almost nothing — and a wrong one for a table: a three-row `rates.csv` holding a going-in and an exit cap rate ingests at well under 200 characters, so `ingest` told the agent to "tell the user the source came back thin", `ls` and the gate marked the row, and the writing skill said not to cite it. The heuristic built to stop an agent citing an empty page stopped it citing a correct, complete rate table and had it say something false about the source, and small and complete is normal for a sheet: an assumptions tab, a rate card, a cap table. `gate.thin_mark` now decides by page kind: an extraction whose pages are all sheets is thin exactly when no cell holds a value, read off the `cells` `Registry.pages` already returns, because a workbook's shell is charts, images or pivot caches with nothing in its grid — and the rendered table's pipes and `[B10]` prefixes make a character count meaningless there anyway. Prose, and the zero-page extraction, keep the character threshold exactly. Page kind rather than extension, so a CSV forced through `--extractor text` is judged as the prose it was extracted as. This amends 2026-08-20 rather than contradicting it: still display only, still exit 0, still no token, anchor, status or byte of the artifact derived from it, and still one owner — the 2026-09-09 single owner is what made this a change in one function that `ingest`'s note and all three list surfaces follow. The mark keeps its wording, `little text: N chars`, on a marked sheet too: the count is still true, an agent that learned the one string keeps reading it, and `cli._THIN_CAUSE` gains a sheet cause shared by `xlsx`, `xls` and `csv` that says what the rule is, so the mark on an empty workbook is not read as a verdict on short tables. A registry's output changes only on the table rows this exists to unmark and on sheet sources that hold no values at all; the demo's three sources print byte for byte what they did, list and tables of contents both. The cost taken: a sheet whose only values are a stray title cell and nothing else is no longer marked, because one value is a value — the rule trades that miss for never again marking a complete table, which is the case that happens. |
166
173
 
167
174
  ## Open
168
175
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: backdraft
3
- Version: 0.12.0
3
+ Version: 0.14.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
@@ -92,7 +92,10 @@ than a page, so the host stands in — and a slug is permanent once tokens carry
92
92
  it. `backdraft ingest <source> --dry-run` is how to see that before choosing:
93
93
  it prints the slug and media type each source would take and stops, fetching
94
94
  nothing and writing nothing, and it says when a source is already ingested or
95
- when the name it wants is taken.
95
+ when the name it wants is taken. Permanent means `--slug` only ever names a new
96
+ document: on a source already ingested the line says `--slug not applied` and a
97
+ note names the slug it keeps — and `forget` then a re-ingest brings back that
98
+ same slug rather than a new one, since identity is the bytes, not the name.
96
99
 
97
100
  Every ingest line ends with how much text came out, and says which of three
98
101
  things happened: a document created, a **new generation** of one whose bytes
@@ -106,7 +109,10 @@ afterwards — `backdraft read`'s document list, its table of contents and
106
109
  `backdraft ls` end its row with `little text: N chars` — because a registry
107
110
  travels with the project folder and whoever writes against it is usually not
108
111
  whoever ingested it. Ordinary sources gain nothing: only the rows worth doubting
109
- say anything.
112
+ say anything. A table is judged by its cells rather than its length — a
113
+ three-row rate card is small and complete, so a spreadsheet or CSV is marked
114
+ only when none of its cells holds a value, which is what a workbook of charts or
115
+ images extracts to.
110
116
 
111
117
  **Read.** The page arrives with a citable name over each chunk. This is the whole
112
118
  mechanism: what you can cite is exactly what you were shown.
@@ -224,11 +230,11 @@ enough to write this yet?", asked before the draft exists rather than after
224
230
  $ backdraft session show
225
231
  session s-bridgeview (from BACKDRAFT_SESSION)
226
232
 
227
- 78 anchors shown across 3 documents
233
+ 83 anchors shown across 3 documents
228
234
 
229
235
  t12-summary 8
230
236
  underwriting-model 68
231
- franklin-county 2
237
+ franklin-county 7
232
238
 
233
239
  [Read more: backdraft read <slug> <page>]
234
240
  ```
@@ -237,8 +243,9 @@ Anything counted there binds `resolved`; anything else in the registry binds
237
243
  `not_shown`. Without an exported session, reads land in one default ledger every
238
244
  run in the project shares and nothing ever resets — so `not_shown` weakens from
239
245
  "this writer never saw it" to "nothing here ever has". `session show` says so at
240
- exit 0 when you are in it, and `backdraft session start --id s-<name>` is the
241
- one command that fixes it.
246
+ exit 0 when you are in it, `bind` names the session on its first line and
247
+ closes with the same note when it judged against the default, and `backdraft
248
+ session start --id s-<name>` is the one command that fixes it.
242
249
 
243
250
  **Cite** by writing the token as the href of a markdown link on the claim span.
244
251
  Multiple citations are `;`-separated in one href.
@@ -252,7 +259,7 @@ Multiple citations are `;`-separated in one href.
252
259
 
253
260
  ```console
254
261
  $ backdraft bind memo.md --session s-bridgeview --check value-trace,overlap
255
- bound 17 claim(s), 18 citation(s) [frontwalk]
262
+ bound 17 claim(s), 18 citation(s) [frontwalk] against session s-bridgeview
256
263
  resolved: 17
257
264
  unresolved: 1
258
265
  overlap: pass 13, skip 4
@@ -260,13 +267,15 @@ bound 17 claim(s), 18 citation(s) [frontwalk]
260
267
  value-trace: pass 17
261
268
  ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @3629
262
269
  wrote .backdraft/records/memo.backdraft.json
270
+ next: unresolved — the token names nothing. `backdraft show <token>` says which half is wrong, and `backdraft search "<the fact>" --session s-bridgeview` finds the real one. A claim no source supports says so in its text; a withdrawn source is replaced by one still ingested, not re-ingested. Never clear one by deleting the token.
263
271
 
264
272
  $ echo $?
265
273
  2
266
274
  ```
267
275
 
268
276
  Exit 2 means a citation did not resolve, the code a `Stop` hook or a CI job
269
- gates on.
277
+ gates on. The closing `next:` line names the command that fixes each kind of
278
+ failure the report printed.
270
279
 
271
280
  **Render.** One self-contained HTML file: the document, the receipts, the
272
281
  evidence, cited page images, spreadsheet cells in place, the machine-readable
@@ -299,7 +308,10 @@ That first tier needs nothing but the file, which is the point — it is the che
299
308
  someone who was emailed the artifact can still make. Run it inside the project
300
309
  the document was bound in and it also re-resolves every citation against the
301
310
  registry, so a source that has changed since shows up as `drifted` rather than
302
- going unnoticed. Exit 0 when everything it checked passed, 2 when something did
311
+ going unnoticed — and asks the ledger session the record names whether it was
312
+ ever shown each citation, so a `not_shown` is re-checked rather than taken on
313
+ the record's word. That session is read, never created: when this registry does
314
+ not have it, a `ledger:` line says so. Exit 0 when everything it checked passed, 2 when something did
303
315
  not, so a hook can gate on it. It opens no session and mints nothing: an audit
304
316
  must not make its subject citable.
305
317
 
@@ -486,6 +498,19 @@ slug, not a second one beside it, so every token it ever minted resolves again.
486
498
  The price of never actually removing anything is that a registry only grows; a
487
499
  corpus you must be able to shrink is a new registry, not a `forget`.
488
500
 
501
+ `ls` lists what is on offer, so a withdrawn source leaves it too, and a registry
502
+ with nothing withdrawn prints exactly what it always did. `ls --withdrawn` is
503
+ where the rest are listed — each with the date it went and the ingest that
504
+ brings it back, which is the one place that path is still written down:
505
+
506
+ ```console
507
+ $ backdraft ls --withdrawn
508
+ scratch-copy scratch-copy.md text 1 page withdrawn from the registry on 2026-10-07T13:03:11.922792Z Re-ingest it to bring it back: backdraft ingest scratch-copy.md
509
+ ```
510
+
511
+ A registry whose every source has been withdrawn says so — `no documents on
512
+ offer: 2 withdrawn` — rather than calling itself empty.
513
+
489
514
  ## What lives where
490
515
 
491
516
  A working directory stays clean: the authored document and its artifact are
@@ -532,6 +557,25 @@ Three extras:
532
557
  `backdraft[vlm]` still installs: it is an empty compat alias from when the
533
558
  vision deps were an extra.
534
559
 
560
+ **In an unfamiliar environment, run `backdraft doctor` first.** Everything
561
+ above degrades rather than fails, so without it each gap is discovered at the
562
+ moment it is missed. `doctor` reports all of them up front — the registry,
563
+ poppler, the vision model, `[xls]`, `[entail]` and `[math]` — each as `ready`
564
+ or `missing`, with what its absence costs the verbs and the command that fixes
565
+ it, in the same words the verb itself would print. Keys are named `set` or
566
+ `absent` and their values never printed. Read-only, and always exit 0: a
567
+ missing optional capability is not an error.
568
+
569
+ ```console
570
+ $ backdraft doctor
571
+ registry ready /…/demo/.backdraft, 3 document(s)
572
+ page images ready poppler renders PDF pages for the artifact's cited-page view
573
+ vision model missing Glossy or scanned PDFs extract better through a vision model: set BACKDRAFT_VLM_API_KEY in .backdraft/env.
574
+ costs ingest: `auto` reads a PDF's text layer, so a scan comes back thin, and an image cannot be ingested at all. A PDF with a text layer, and every other format, is unaffected.
575
+ key BACKDRAFT_VLM_API_KEY absent
576
+ …
577
+ ```
578
+
535
579
  From a checkout:
536
580
 
537
581
  ```bash
@@ -65,7 +65,10 @@ than a page, so the host stands in — and a slug is permanent once tokens carry
65
65
  it. `backdraft ingest <source> --dry-run` is how to see that before choosing:
66
66
  it prints the slug and media type each source would take and stops, fetching
67
67
  nothing and writing nothing, and it says when a source is already ingested or
68
- when the name it wants is taken.
68
+ when the name it wants is taken. Permanent means `--slug` only ever names a new
69
+ document: on a source already ingested the line says `--slug not applied` and a
70
+ note names the slug it keeps — and `forget` then a re-ingest brings back that
71
+ same slug rather than a new one, since identity is the bytes, not the name.
69
72
 
70
73
  Every ingest line ends with how much text came out, and says which of three
71
74
  things happened: a document created, a **new generation** of one whose bytes
@@ -79,7 +82,10 @@ afterwards — `backdraft read`'s document list, its table of contents and
79
82
  `backdraft ls` end its row with `little text: N chars` — because a registry
80
83
  travels with the project folder and whoever writes against it is usually not
81
84
  whoever ingested it. Ordinary sources gain nothing: only the rows worth doubting
82
- say anything.
85
+ say anything. A table is judged by its cells rather than its length — a
86
+ three-row rate card is small and complete, so a spreadsheet or CSV is marked
87
+ only when none of its cells holds a value, which is what a workbook of charts or
88
+ images extracts to.
83
89
 
84
90
  **Read.** The page arrives with a citable name over each chunk. This is the whole
85
91
  mechanism: what you can cite is exactly what you were shown.
@@ -197,11 +203,11 @@ enough to write this yet?", asked before the draft exists rather than after
197
203
  $ backdraft session show
198
204
  session s-bridgeview (from BACKDRAFT_SESSION)
199
205
 
200
- 78 anchors shown across 3 documents
206
+ 83 anchors shown across 3 documents
201
207
 
202
208
  t12-summary 8
203
209
  underwriting-model 68
204
- franklin-county 2
210
+ franklin-county 7
205
211
 
206
212
  [Read more: backdraft read <slug> <page>]
207
213
  ```
@@ -210,8 +216,9 @@ Anything counted there binds `resolved`; anything else in the registry binds
210
216
  `not_shown`. Without an exported session, reads land in one default ledger every
211
217
  run in the project shares and nothing ever resets — so `not_shown` weakens from
212
218
  "this writer never saw it" to "nothing here ever has". `session show` says so at
213
- exit 0 when you are in it, and `backdraft session start --id s-<name>` is the
214
- one command that fixes it.
219
+ exit 0 when you are in it, `bind` names the session on its first line and
220
+ closes with the same note when it judged against the default, and `backdraft
221
+ session start --id s-<name>` is the one command that fixes it.
215
222
 
216
223
  **Cite** by writing the token as the href of a markdown link on the claim span.
217
224
  Multiple citations are `;`-separated in one href.
@@ -225,7 +232,7 @@ Multiple citations are `;`-separated in one href.
225
232
 
226
233
  ```console
227
234
  $ backdraft bind memo.md --session s-bridgeview --check value-trace,overlap
228
- bound 17 claim(s), 18 citation(s) [frontwalk]
235
+ bound 17 claim(s), 18 citation(s) [frontwalk] against session s-bridgeview
229
236
  resolved: 17
230
237
  unresolved: 1
231
238
  overlap: pass 13, skip 4
@@ -233,13 +240,15 @@ bound 17 claim(s), 18 citation(s) [frontwalk]
233
240
  value-trace: pass 17
234
241
  ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @3629
235
242
  wrote .backdraft/records/memo.backdraft.json
243
+ next: unresolved — the token names nothing. `backdraft show <token>` says which half is wrong, and `backdraft search "<the fact>" --session s-bridgeview` finds the real one. A claim no source supports says so in its text; a withdrawn source is replaced by one still ingested, not re-ingested. Never clear one by deleting the token.
236
244
 
237
245
  $ echo $?
238
246
  2
239
247
  ```
240
248
 
241
249
  Exit 2 means a citation did not resolve, the code a `Stop` hook or a CI job
242
- gates on.
250
+ gates on. The closing `next:` line names the command that fixes each kind of
251
+ failure the report printed.
243
252
 
244
253
  **Render.** One self-contained HTML file: the document, the receipts, the
245
254
  evidence, cited page images, spreadsheet cells in place, the machine-readable
@@ -272,7 +281,10 @@ That first tier needs nothing but the file, which is the point — it is the che
272
281
  someone who was emailed the artifact can still make. Run it inside the project
273
282
  the document was bound in and it also re-resolves every citation against the
274
283
  registry, so a source that has changed since shows up as `drifted` rather than
275
- going unnoticed. Exit 0 when everything it checked passed, 2 when something did
284
+ going unnoticed — and asks the ledger session the record names whether it was
285
+ ever shown each citation, so a `not_shown` is re-checked rather than taken on
286
+ the record's word. That session is read, never created: when this registry does
287
+ not have it, a `ledger:` line says so. Exit 0 when everything it checked passed, 2 when something did
276
288
  not, so a hook can gate on it. It opens no session and mints nothing: an audit
277
289
  must not make its subject citable.
278
290
 
@@ -459,6 +471,19 @@ slug, not a second one beside it, so every token it ever minted resolves again.
459
471
  The price of never actually removing anything is that a registry only grows; a
460
472
  corpus you must be able to shrink is a new registry, not a `forget`.
461
473
 
474
+ `ls` lists what is on offer, so a withdrawn source leaves it too, and a registry
475
+ with nothing withdrawn prints exactly what it always did. `ls --withdrawn` is
476
+ where the rest are listed — each with the date it went and the ingest that
477
+ brings it back, which is the one place that path is still written down:
478
+
479
+ ```console
480
+ $ backdraft ls --withdrawn
481
+ scratch-copy scratch-copy.md text 1 page withdrawn from the registry on 2026-10-07T13:03:11.922792Z Re-ingest it to bring it back: backdraft ingest scratch-copy.md
482
+ ```
483
+
484
+ A registry whose every source has been withdrawn says so — `no documents on
485
+ offer: 2 withdrawn` — rather than calling itself empty.
486
+
462
487
  ## What lives where
463
488
 
464
489
  A working directory stays clean: the authored document and its artifact are
@@ -505,6 +530,25 @@ Three extras:
505
530
  `backdraft[vlm]` still installs: it is an empty compat alias from when the
506
531
  vision deps were an extra.
507
532
 
533
+ **In an unfamiliar environment, run `backdraft doctor` first.** Everything
534
+ above degrades rather than fails, so without it each gap is discovered at the
535
+ moment it is missed. `doctor` reports all of them up front — the registry,
536
+ poppler, the vision model, `[xls]`, `[entail]` and `[math]` — each as `ready`
537
+ or `missing`, with what its absence costs the verbs and the command that fixes
538
+ it, in the same words the verb itself would print. Keys are named `set` or
539
+ `absent` and their values never printed. Read-only, and always exit 0: a
540
+ missing optional capability is not an error.
541
+
542
+ ```console
543
+ $ backdraft doctor
544
+ registry ready /…/demo/.backdraft, 3 document(s)
545
+ page images ready poppler renders PDF pages for the artifact's cited-page view
546
+ vision model missing Glossy or scanned PDFs extract better through a vision model: set BACKDRAFT_VLM_API_KEY in .backdraft/env.
547
+ costs ingest: `auto` reads a PDF's text layer, so a scan comes back thin, and an image cannot be ingested at all. A PDF with a text layer, and every other format, is unaffected.
548
+ key BACKDRAFT_VLM_API_KEY absent
549
+ …
550
+ ```
551
+
508
552
  From a checkout:
509
553
 
510
554
  ```bash