backdraft 0.10.0__tar.gz → 0.11.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 (172) hide show
  1. {backdraft-0.10.0 → backdraft-0.11.0}/.claude-plugin/plugin.json +1 -1
  2. {backdraft-0.10.0 → backdraft-0.11.0}/DESIGN.md +4 -0
  3. {backdraft-0.10.0 → backdraft-0.11.0}/PKG-INFO +46 -4
  4. {backdraft-0.10.0 → backdraft-0.11.0}/README.md +45 -3
  5. {backdraft-0.10.0 → backdraft-0.11.0}/ROADMAP.md +207 -155
  6. {backdraft-0.10.0 → backdraft-0.11.0}/SPEC.md +20 -5
  7. {backdraft-0.10.0 → backdraft-0.11.0}/demo/memo.backdraft.html +2 -2
  8. {backdraft-0.10.0 → backdraft-0.11.0}/demo/walkthrough.md +81 -4
  9. {backdraft-0.10.0 → backdraft-0.11.0}/pyproject.toml +1 -1
  10. {backdraft-0.10.0 → backdraft-0.11.0}/site/demo.html +2 -2
  11. {backdraft-0.10.0 → backdraft-0.11.0}/site/docs.html +20 -7
  12. {backdraft-0.10.0 → backdraft-0.11.0}/site/llms.txt +38 -4
  13. {backdraft-0.10.0 → backdraft-0.11.0}/skills/backdraft/SKILL.md +46 -4
  14. {backdraft-0.10.0 → backdraft-0.11.0}/skills/backdraft-backfill/SKILL.md +3 -1
  15. {backdraft-0.10.0 → backdraft-0.11.0}/spec/artifact.md +6 -1
  16. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/bind/evidence.py +41 -6
  17. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/cli.py +177 -31
  18. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/html.py +18 -3
  19. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/fetch.py +19 -6
  20. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/gate/__init__.py +6 -0
  21. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/gate/cli.py +52 -8
  22. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/gate/reader.py +254 -39
  23. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/gate/searcher.py +20 -9
  24. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/kernel/artifact.py +3 -0
  25. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/registry/__init__.py +2 -0
  26. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/registry/store.py +79 -1
  27. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/html/text.py +18 -9
  28. backdraft-0.11.0/tests/continuation_util.py +32 -0
  29. {backdraft-0.10.0 → backdraft-0.11.0}/tests/gate/fake_registry.py +11 -1
  30. {backdraft-0.10.0 → backdraft-0.11.0}/tests/gate/test_gate_cli.py +87 -1
  31. {backdraft-0.10.0 → backdraft-0.11.0}/tests/gate/test_gate_reader.py +408 -15
  32. {backdraft-0.10.0 → backdraft-0.11.0}/tests/gate/test_gate_searcher.py +26 -0
  33. {backdraft-0.10.0 → backdraft-0.11.0}/tests/gate/test_reader_show.py +11 -0
  34. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/render/backfill.sidecar.json +1 -1
  35. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/render/demo.sidecar.json +1 -1
  36. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_bind_evidence.py +70 -1
  37. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_cli.py +85 -0
  38. backdraft-0.11.0/tests/test_dry_run.py +383 -0
  39. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_extract_html.py +22 -0
  40. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_gate_integration.py +116 -5
  41. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_html.py +29 -0
  42. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_registry.py +85 -0
  43. {backdraft-0.10.0 → backdraft-0.11.0}/uv.lock +1 -1
  44. {backdraft-0.10.0 → backdraft-0.11.0}/.claude-plugin/marketplace.json +0 -0
  45. {backdraft-0.10.0 → backdraft-0.11.0}/.github/workflows/ci.yml +0 -0
  46. {backdraft-0.10.0 → backdraft-0.11.0}/.github/workflows/release.yml +0 -0
  47. {backdraft-0.10.0 → backdraft-0.11.0}/.github/workflows/site.yml +0 -0
  48. {backdraft-0.10.0 → backdraft-0.11.0}/.gitignore +0 -0
  49. {backdraft-0.10.0 → backdraft-0.11.0}/.python-version +0 -0
  50. {backdraft-0.10.0 → backdraft-0.11.0}/LICENSE +0 -0
  51. {backdraft-0.10.0 → backdraft-0.11.0}/assets/backdraft-mark.svg +0 -0
  52. {backdraft-0.10.0 → backdraft-0.11.0}/demo/generate_sources.py +0 -0
  53. {backdraft-0.10.0 → backdraft-0.11.0}/demo/memo.md +0 -0
  54. {backdraft-0.10.0 → backdraft-0.11.0}/demo/sources/t12-summary.pdf +0 -0
  55. {backdraft-0.10.0 → backdraft-0.11.0}/demo/sources/underwriting-model.xlsx +0 -0
  56. {backdraft-0.10.0 → backdraft-0.11.0}/site/index.html +0 -0
  57. {backdraft-0.10.0 → backdraft-0.11.0}/site/privacy.html +0 -0
  58. {backdraft-0.10.0 → backdraft-0.11.0}/skills/backdraft-artifact/SKILL.md +0 -0
  59. {backdraft-0.10.0 → backdraft-0.11.0}/spec/chunking.md +0 -0
  60. {backdraft-0.10.0 → backdraft-0.11.0}/spec/registry.md +0 -0
  61. {backdraft-0.10.0 → backdraft-0.11.0}/spec/tokens.md +0 -0
  62. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/__init__.py +0 -0
  63. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/__main__.py +0 -0
  64. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/bind/__init__.py +0 -0
  65. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/bind/binder.py +0 -0
  66. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/bind/cli.py +0 -0
  67. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/bind/verify/__init__.py +0 -0
  68. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/bind/verify/base.py +0 -0
  69. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/bind/verify/entail.py +0 -0
  70. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/bind/verify/overlap.py +0 -0
  71. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/bind/verify/recompute.py +0 -0
  72. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/bind/verify/value_trace.py +0 -0
  73. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/cli_context.py +0 -0
  74. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/credentials.py +0 -0
  75. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/__init__.py +0 -0
  76. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/base.py +0 -0
  77. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/csv.py +0 -0
  78. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/docx.py +0 -0
  79. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/image.py +0 -0
  80. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/pdf_text.py +0 -0
  81. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/pptx.py +0 -0
  82. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/sheet.py +0 -0
  83. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/snapshots.py +0 -0
  84. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/text.py +0 -0
  85. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/vlm.py +0 -0
  86. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/vlm_settings.py +0 -0
  87. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/xls.py +0 -0
  88. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/extract/xlsx.py +0 -0
  89. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/kernel/__init__.py +0 -0
  90. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/kernel/chunking.py +0 -0
  91. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/kernel/claims.py +0 -0
  92. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/kernel/errors.py +0 -0
  93. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/kernel/hashing.py +0 -0
  94. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/kernel/model.py +0 -0
  95. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/kernel/tokens.py +0 -0
  96. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/registry/ledger.py +0 -0
  97. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/registry/schema.sql +0 -0
  98. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/__init__.py +0 -0
  99. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/_text.py +0 -0
  100. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/cli.py +0 -0
  101. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/footnotes.py +0 -0
  102. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/html/__init__.py +0 -0
  103. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/html/assets.py +0 -0
  104. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/html/components.py +0 -0
  105. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/html/fmt.py +0 -0
  106. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/html/fmt_vectors.py +0 -0
  107. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/html/page.py +0 -0
  108. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/markdown.py +0 -0
  109. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/math.py +0 -0
  110. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/placement.py +0 -0
  111. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/sidecar.py +0 -0
  112. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/theme.py +0 -0
  113. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/themes/default.toml +0 -0
  114. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/themes/press.toml +0 -0
  115. {backdraft-0.10.0 → backdraft-0.11.0}/src/backdraft/render/themes/slate.toml +0 -0
  116. {backdraft-0.10.0 → backdraft-0.11.0}/tests/conftest.py +0 -0
  117. {backdraft-0.10.0 → backdraft-0.11.0}/tests/conftest_registry.py +0 -0
  118. {backdraft-0.10.0 → backdraft-0.11.0}/tests/conftest_render.py +0 -0
  119. {backdraft-0.10.0 → backdraft-0.11.0}/tests/css_util.py +0 -0
  120. {backdraft-0.10.0 → backdraft-0.11.0}/tests/fakes.py +0 -0
  121. {backdraft-0.10.0 → backdraft-0.11.0}/tests/gate/conftest.py +0 -0
  122. {backdraft-0.10.0 → backdraft-0.11.0}/tests/gate/test_gate_ledger.py +0 -0
  123. {backdraft-0.10.0 → backdraft-0.11.0}/tests/gate/test_reader_cells.py +0 -0
  124. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/bind/backfill.bound.md +0 -0
  125. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/bind/frontwalk.bound.md +0 -0
  126. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/chunking/long_tail.json +0 -0
  127. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/chunking/long_tail.txt +0 -0
  128. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/chunking/many_small.json +0 -0
  129. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/chunking/many_small.txt +0 -0
  130. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/chunking/mixed.json +0 -0
  131. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/chunking/mixed.txt +0 -0
  132. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/chunking/one_giant.json +0 -0
  133. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/chunking/one_giant.txt +0 -0
  134. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/chunking/short_page.json +0 -0
  135. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/chunking/short_page.txt +0 -0
  136. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/chunking/unsplittable.json +0 -0
  137. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/chunking/unsplittable.txt +0 -0
  138. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/render/backfill.footnotes.md +0 -0
  139. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden/render/demo.footnotes.md +0 -0
  140. {backdraft-0.10.0 → backdraft-0.11.0}/tests/golden_util.py +0 -0
  141. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_bind_binder.py +0 -0
  142. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_bind_cli.py +0 -0
  143. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_bind_verify.py +0 -0
  144. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_card_sizing.py +0 -0
  145. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_chunking.py +0 -0
  146. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_claims.py +0 -0
  147. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_credentials.py +0 -0
  148. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_docs.py +0 -0
  149. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_extract.py +0 -0
  150. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_fetch.py +0 -0
  151. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_fmt_parity.py +0 -0
  152. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_footnotes.py +0 -0
  153. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_forget.py +0 -0
  154. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_hashing.py +0 -0
  155. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_ingest_url.py +0 -0
  156. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_invariants.py +0 -0
  157. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_model.py +0 -0
  158. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_placement.py +0 -0
  159. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_records_location.py +0 -0
  160. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_reingest.py +0 -0
  161. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_render_cli.py +0 -0
  162. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_render_markdown.py +0 -0
  163. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_render_math.py +0 -0
  164. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_sidecar.py +0 -0
  165. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_snapshot_settings.py +0 -0
  166. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_snapshots.py +0 -0
  167. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_spec_registry.py +0 -0
  168. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_theme.py +0 -0
  169. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_tokens.py +0 -0
  170. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_value_trace.py +0 -0
  171. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_verify_cli.py +0 -0
  172. {backdraft-0.10.0 → backdraft-0.11.0}/tests/test_version.py +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "backdraft",
3
3
  "displayName": "Backdraft",
4
- "version": "0.10.0",
4
+ "version": "0.11.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",
@@ -155,6 +155,10 @@ Self-contained HTML (plus the machine-readable sidecar): **quotes + receipts + e
155
155
  | 2026-09-01 | **An unreadable source says what to do about it, and an empty one is a failure.** `ingest` finishing its list (2026-08-13) turned the reason string into something a calling agent actually reads, and the reason was Python: `cannot read adir: [Errno 21] Is a directory: 'adir'` named the source three times, leaked an errno nobody should have to know, and said nothing about a fix that differs completely between the causes and is obvious in each one. The read now goes through `store._read_source`, which maps the four causes worth distinguishing — a directory (name the files inside it, or pass a glob), a path with nothing at it (check the spelling), a file the process may not open (fix the permissions, or ingest a copy), and zero bytes — and `fetch.py`'s messages follow the same rule for the URL half. Anything outside those four keeps the operating system's own `strerror` rather than being guessed at: an unknown cause reported plainly beats a wrong next step, and that is the one place an errno-free message loses information, which is the trade taken. The reason never names the source, because every caller already leads with it — which means the exception read on its own, by a library caller rather than through `ingest`'s `!` line, is now less self-contained than it was; the CLI is the only caller and the report is the surface that matters. Empty is the substantive change rather than a rewording: zero bytes read cleanly, so an empty file used to become a document — listed by `ls`, offered by `read`, competing in `search`, citable in name only — and a fetched page whose server sent an empty body did the same, which is the shape a JavaScript-rendered page takes here. That is not the thin-source case the 2026-08-20 row admitted a heuristic for: thin is a judgement about how much text came out of real bytes and stays display-only at exit 0, while zero bytes is a fact and there is no snapshot to make. Staging a fetched page also became a per-source failure rather than a traceback, so a full temporary directory no longer ends the run the rest of the list was promised. |
156
156
  | 2026-09-02 | **A registry can forget a source, and forgetting removes nothing.** Ingest was one-way, and now that `ingest` finishes its list the unattended folder run is the normal case — so a scratch copy, the same report under two names, or a file nobody meant to include lands and stays: listed by `read`, competing in `search`, and citable by an agent choosing evidence without anything looking wrong. The only recovery was deleting `.backdraft/`, which throws away every other document and the whole ledger. `backdraft forget <slug>` **withdraws** rather than deletes, and the whole design is in that word: the document, its generations, its anchors and its receipts all stay, so `resolve` still finds every anchor and a token already written into somebody's draft or artifact still shows its snippet under `show`. What a withdrawal changes is that the registry stops *offering* the source — `documents()` excludes it, and with it `ls`, the gate's list, its table of contents, page reads and `search`, whose filter sits in the shared `_match_sql` so the pre-limit `total` a caller is offered to widen to drops too. A deletion would have stranded those citations with nothing to say about them, which is the failure this system exists to prevent. The status is the second half of the decision and the temptation was a sixth one: `citation_for` reports a withdrawn source's token `unresolved` carrying `withdrawn_reason` as its `error`, because the closed status set is the artifact format's and a new value would break every reader written against it, while `resolved` would let a document keep citing a source the registry no longer offers and report nothing. That widens `error` from `malformed`'s explanation to any status the status alone does not explain — an optional key appearing where it applies, not a new format version — and it made the legend's own `unresolved` line wrong, since a withdrawn citation *does* carry an anchor; corrected in `LEGEND` and `spec/artifact.md` at the cost of regenerated goldens and a re-rendered demo, the 2026-08-24 trade. Storage is a `withdrawals` table rather than a column on `documents`, which is the 2026-08-05 reason `document_meta` is one: an existing registry needs no migration, and this repo has no migration machinery to introduce for one flag. Ingesting the source again is the undo and deletes the row, bringing back the *same* document under the same slug — `_find_document` matches a withdrawn document like any other, which is exactly what a delete could not do, since a second slug would leave every token carrying the first one stranded. That undo is silent-by-default territory, so it is not: `Ingested.restored` is a fact beside `outcome` rather than a fourth value of it (a forgotten file that was also edited is both restored and a new generation, and one field could only say one), the line says `restored`, and a grouped note names what came back. Two costs stated rather than discovered. A registry only ever grows: nothing is reclaimed, and a corpus you must be able to shrink is a new registry, not a `forget`. And the rule is drawn at *offering a source to read*, so `snapshot-pages` still backfills page images for a withdrawn document — it serves no text and cannot reach a writer's context, and widening the rule to every command that touches a document would make it a rule nobody can state. `forget` asks before withdrawing and, where nothing can answer, names `--yes` instead of hanging on a prompt or aborting without a reason, since the usual caller here is an agent with no terminal. |
157
157
  | 2026-09-03 | **The rule moved down to the type it is about, and the last surface naming a source wrongly stopped.** The 2026-08-18 row gave `gate.source_name` one owner and accepted one exception on a dependency argument, and the argument was sound about imports and wrong about where the rule belonged. Bind's `_doc_name` returned `Document.filename`, and it feeds *both* halves of the markdown projection — the generated `## References` section and backfill's proposed anchors — so `bind --bound` called the demo's Wikipedia article `index.html` while `render --to footnotes` named its URL and the artifact named it *Franklin County*. Three of backdraft's own outputs, two answers, and the one that disagreed was the one a reader gets without a browser: the bound markdown is the form that travels into a pull request or an email, where the HTML artifact cannot follow. The fix is to stop working around the dependency rule and read it properly. `source_name` is a pure function of a `Document`, `Document` is defined in `kernel/model.py`, and gate, bind and the CLI all import that module *downward* — so the kernel was the owner the rule was looking for all along, and it stays stdlib-pure there. SPEC § Dependency rule now says so in general, because the reasoning that produced the exception ("we cannot share it sideways, so we duplicate or we live with it") will recur for the next shared rule and the escape hatch should not have to be rediscovered. `gate.source_name` is **not** left as a redirect: callers import `kernel.model.source_name` and the gate's re-export is gone, since two import paths for one function is the same shape as two names for one source — the thing the 2026-08-06 row rejected — and the gate's stated export style is names it owns, not names it passes through. The cost is that anything importing `from backdraft.gate import source_name` breaks; it is one internal caller and a library surface nobody outside this repo is known to use, and leaving both paths alive is what makes an owner stop being one. `_origin` moved with it as public `source_origin`, because the gate's document list needs the *question* ("is this name a URL, and therefore must not size the name column?") and not just the answer, and two readings of one `meta` key drift apart the moment the key does. Display only, unchanged: the slug, the token and everything a citation resolves through are untouched, and a References entry for a file source is byte-identical to what it printed before — pinned as a whole line, the way `ls`'s file rows are. The render side was deliberately not touched and was never wrong: `render/html/text.source_title` and `render/footnotes._origin` read a `url` off the *artifact's* `docs` dict, which is the artifact format's shape rather than a `Document`, and they already prefer the slug and the URL over the invented filename — they look like misses and are not. One thing this closes that was already promised in prose: `demo/walkthrough.md` told readers that a staging filename appears at "no surface that names a source", which was false for as long as this row's exception stood. |
158
+ | 2026-09-07 | **A one-page source's table of contents is its chunks, and a page that named itself is called by that name.** `backdraft read franklin-county` opened with `Jump to content Main menu move to sidebar hide Navigation`, which is the site's chrome and not the page, and it was the *only* thing the table of contents said about a 34,000-character article — because a web page has no pagination, so the page list is one row and does no navigating at all. Two halves, one fact. The listing half: a document that is a single page of prose now lists that page's chunks underneath it, `p1.c1 opening words` per row, read off `anchors_for_page` and never re-chunked (rule 1 of the reader), so every locator printed is one a token already carries. The page's own preview goes with it, because it *was* the first chunk's opening words said twice, and a page row left carrying nothing but `p1` is dropped, since every chunk row below already begins with it. Scoped to one page rather than to HTML: a one-page PDF has the same problem, and a single-sheet workbook mints cell anchors and no chunk anchors, so it lists nothing and prints what it always did — as do all three of the demo's sources except the article, pinned byte for byte. The naming half: `extract/html.py` already parsed the `<title>` and threw away the fact that it *found* one, so `Page.meta` now carries `title` and carries it only where the markup declared it. `Page.name` could not answer the question, and that is the whole reason for a second field holding the same string: `name` falls back to the file stem, and a fetched page's stem is the one `fetch.filename_for` invented — so a page called `index` and a page called *Franklin County, Ohio - Wikipedia* were indistinguishable to anything downstream. Bind copies it into the evidence entry (`spec/artifact.md` § evidence, optional, absent for everything else) and `render/html/text.source_title` prefers it, which is what stops a URL ingested without `--slug` from titling itself *En Wikipedia Org Index* in the artifact's source list. Only a single-page source answers, because page 1's title is the document's title exactly when page 1 is the document; a section heading must not name a whole report. Display only, and the 2026-08-20 thin-source argument is the one that admits it: no chunk boundary moves, no anchor moves, no token changes, the slug is still not derived from the title, and the demo's 18 tokens are identical before and after. That is also why this is not the boilerplate stripping the extractor refuses — a heuristic that changes its mind moves anchors; this reorders a preview. One deviation from the queued shape, stated rather than buried: the item asked for `_preview` to prefer the title too, and it must not, because the gate already had the title in `Page.name` and every page that would reach that branch has its chunks listed instead — the branch is unreachable, and unreachable code is what Friday deletes. One cost taken knowingly: the demo's registry predates the page meta and re-ingesting the same bytes is a no-op by design, so the demo artifact still names that source from its slug and will pick up the title the next time the registry is rebuilt. Re-fetching to force it is the one thing that must not happen — Wikipedia's chrome carries the render date, so the bytes differ, the chunks differ and every token in the demo memo breaks. |
159
+ | 2026-09-08 | **A page read has a budget, and the line that closes a short read is a command that runs.** `backdraft read franklin-county p1` printed 36,442 characters and stopped on `49 languages Add topic`, which reads exactly like a truncation and was not one: `--limit` defaulted to *all of them*, so the continuation line this repo is proud of — `[Showing 0-464 of 3115 chars. Continue with: …]` — only ever appeared to a caller who already knew to pass `--limit`. The gate is the one surface an agent is required to use, which makes the default the case that matters, and unbounded meant a single read could spend a large fraction of a context window with nothing in the output to say so — the same silent-cap family as the 2026-08-26 `search` row, from the other end. The choice was a default budget against staying unbounded and *always* closing with the size, and byte-identity settled it: always-closing puts a new line on every read of every small page, and a small page is both the common case and a contract, so `DEFAULT_BUDGET` in `gate/reader.py` is 12,000 characters and 200 sheet rows — roughly three full pages of prose, and about the same spend counted the way a sheet is — while the demo's `read t12-summary p1`, `p1-3` and `assumptions` come back byte for byte what they were. The budget is **soft**, which is the half that keeps the format honest: `_Window.take` was already offering whole chunks and whole rows, so a chunk longer than the whole budget is still shown whole rather than cut under its token, and no token ever stands above text the reader received only part of — now pinned by a test that walks every window at five limits and compares each printed block against the anchor's own snippet, rather than left as a property the code happens to have. What a read shows is unchanged; only how much of it arrives at once. The closing line then had to become the thing it claimed to be, because a budget makes it the normal path rather than the expert one, and it was wrong in three ways nobody had hit: it dropped the caller's `--limit`, so continuing a deliberately narrow read silently widened it to the default; it dropped `--session`, so continuing a read performed under `--session s-deal` minted the rest of the page into the default ledger and the writer's own reading came back `not_shown` — the one failure this mechanism exists to catch, manufactured by the hint telling the truth badly; and it did not quote the selector, so a page read by its name (`Franklin County, Ohio - Wikipedia`) named a command no shell could run, which `searcher._widen_hint` had already learned to avoid. The session flag is keyed on the **rule** that supplied the id and not on the id, which is the opposite of the 2026-08-31 note and right for the opposite reason: that note warns about a shared ledger however you reached it, while this asks whether the id survives the command — an exported `BACKDRAFT_SESSION` does and needs no flag, a typed `--session` does not. `gate.cli` makes that call and the reader owns the wording, the same split `session show` makes for its note, so the gate's library half still does not import the CLI's constants. The default is not echoed back as a `--limit`: a command carrying 12000 forward would go on meaning 12000 after the number moved. Two costs stated. There is no `--all`; the way to read a long page in one call is the `--limit` the closing line already names, which is why that line reports the total and not just the remainder. And `demo/walkthrough.md` quotes the closing line rather than adding the page read that produces it — the walkthrough's `session show` block is the ledger a clean run leaves behind, a new minting command would move those counts, and the numbers cannot be regenerated without re-fetching a source whose bytes carry a render date and would break every token in the demo. |
160
+ | 2026-09-09 | **A source that came back a shell says so on every surface that lists it, not only in the `ingest` that printed the note.** The 2026-08-20 row gave `ingest` a character count and a `note: little text extracted` naming the cause, and both were printed once and then gone — nothing stored the signal and no other surface carried it, so off `backdraft read`'s list a 34,000-character article and a 27-character login wall were the same row. The asymmetry is the point: the skill says to ingest everything up front and then write, a registry travels with the project folder, and the agent choosing evidence is usually not the process that ingested — so the one signal that matters was the one it was least likely to be present for. `gate.thin_mark` now closes the row of a thin source in the gate's document list, in its table-of-contents headline (inside the parenthesis, where everything else describing the source already lives) and in `ls`, reading `little text: 27 chars`; the count is there because 0 characters and 190 are different problems. Reporting, not storage: the count is `sum(len(page.text))` over the current extraction, so nothing was added to the registry and no migration exists to have gone wrong. The owner is `gate/reader.py` beside `unit` rather than `kernel.model` beside `source_name`, which is the other precedent and the wrong one here: the kernel is the spec's vocabulary, `source_name` is a pure rule about a `Document` that bind also needs, and this is a **heuristic** about how a surface describes an extraction — putting a tunable threshold down there would read as normative to the next implementation, and only the gate and the CLI (which imports downward from it, as it already does for `unit`) ever ask. `cli.THIN_SOURCE_CHARS` is gone; `ingest`'s own line, its grouped note and all three list surfaces now derive the same number from `gate.extracted_chars`, which is the whole of the item — a second implementation of the count would have defeated it. Only thin rows are marked, so a registry of ordinary sources gains no column and prints byte for byte what it always did — the gate's count column is padded and `_block`'s existing rstrip takes the padding back off an unmarked row, and `ls` adds a fifth tab-separated field only where there is one to add. That fifth field is the same slot the 2026-08-18 row deliberately emptied when `ls` stopped printing a fetched source's URL beside its invented filename, and the difference is worth stating: that field was a second name for a thing that already had one, this one appears only on the rows nothing else says anything about. Two costs. The heuristic now lives on a persistent surface rather than in a note that scrolled away, so a genuinely tiny real source carries the mark for as long as it is in the registry — accepted on the 2026-08-20 terms, which still hold exactly: display only, exit 0, and no token, anchor, status or byte of the artifact derives from it. And the gate's own fixtures had to grow: the miniature standing in for a 34,000-character Wikipedia article held 112 characters, which would have pinned `little text` into the golden strings that document the list format — a fixture that small was already lying about what it represented, and the marks in `tests/gate` are now on sources that really are shells. |
161
+ | 2026-09-10 | **What a source will be called is a question `ingest` answers with `--dry-run`, not a sibling command.** Three docs tell an agent to pass `--slug` when it ingests a URL, because the default may name a site rather than a page and a slug is permanent the moment a token carries it — and none of them let the agent find out what the default would be. The fix is that the question is cheap: `fetch.filename_for` settles the stem from the address alone (the 2026-08-19 row's own words), so the only way to ask it was to ingest and live with the answer, and getting it wrong costs a re-ingest under a new slug plus a rewritten draft. The shape considered against a flag was a sibling command — `backdraft name <source>` — and the objection to a flag is real: a verb that does not do its verb reads badly, and `ingest` is a writing verb. `--dry-run` answers it, and that is the whole reason it is spelled that way rather than `--plan` or `--name`: the convention is universal and says exactly the thing the objection worries about, that the verb did not run. Two things settled it beyond the wording. The question arises *while composing the ingest command*, so the answer belongs on that command's own help, next to the `--slug` it exists to inform — a separate noun is discoverable only by reading the top-level help of a CLI whose surface is deliberately four verbs. And the prediction has to be the outcome, which means taking `ingest`'s arguments and walking `ingest`'s path: `Registry.naming` matches identity through the same `_find_document` and assigns through the same `_assign_slug`, so it cannot drift from the thing it predicts. Where the two must differ is stated rather than hidden. A file's bytes are read, because identity is the bytes and a copy under another name is the same document keeping its first slug. A URL's are not, so continuity rests on the URL — which is what a re-fetch matches on anyway — and the honest gap, printed as a note rather than guessed at, is the media type: a slug comes from the address and is settled here, a media type comes from the content type the server sends and is not. A source already ingested reports its recorded media type instead, because that one *was* settled by a server. The registry's part of the answer is the part a name alone cannot give: already ingested (and withdrawn, since `ls` denies those exist), the numbered slug a taken name would land on, and that `--slug` will not rename a document that has one. Read-only throughout — no socket, no document, no anchor, no ledger row — and the test that pins it blocks `fetch.fetch` outright rather than checking the output for absence. |
158
162
 
159
163
  ## Open
160
164
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: backdraft
3
- Version: 0.10.0
3
+ Version: 0.11.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
@@ -86,13 +86,26 @@ link* — `?oldid=` serves one revision's bytes forever — because a citation i
86
86
  a live article reports `drifted` the moment somebody edits it, and the quoted
87
87
  sentence would no longer be the sentence there.
88
88
 
89
+ `--slug` is there because that URL's default name would be
90
+ `en-wikipedia-org-index` — its last segment is `/index.php`, a handler rather
91
+ than a page, so the host stands in — and a slug is permanent once tokens carry
92
+ it. `backdraft ingest <source> --dry-run` is how to see that before choosing:
93
+ it prints the slug and media type each source would take and stops, fetching
94
+ nothing and writing nothing, and it says when a source is already ingested or
95
+ when the name it wants is taken.
96
+
89
97
  Every ingest line ends with how much text came out, and says which of three
90
98
  things happened: a document created, a **new generation** of one whose bytes
91
99
  moved — which is when citations into the previous snapshot start reporting
92
100
  `drifted` — or `unchanged`, a no-op because re-running would reproduce what is
93
101
  already there. When almost no text came out, a note names the likely cause (a
94
102
  scan with no text layer, a page behind a login) and what to do; it is a note at
95
- exit 0, because a thin snapshot is still a real one.
103
+ exit 0, because a thin snapshot is still a real one. That source keeps the mark
104
+ afterwards — `backdraft read`'s document list, its table of contents and
105
+ `backdraft ls` end its row with `little text: N chars` — because a registry
106
+ travels with the project folder and whoever writes against it is usually not
107
+ whoever ingested it. Ordinary sources gain nothing: only the rows worth doubting
108
+ say anything.
96
109
 
97
110
  **Read.** The page arrives with a citable name over each chunk. This is the whole
98
111
  mechanism: what you can cite is exactly what you were shown.
@@ -102,7 +115,7 @@ $ backdraft read t12-summary p1
102
115
  # t12-summary p1 (page 1 of 3)
103
116
 
104
117
  [bd:t12-summary:p1.c1:c2e8]
105
- Bridgeview Commons, Trailing Twelve Month Summary
118
+ Bridgeview Commons — Trailing Twelve Month Summary
106
119
 
107
120
  Property: Bridgeview Commons, 4400 Halsted Avenue, Columbus, OH 43214. 128 units across four three-story
108
121
  garden buildings on 6.2 acres, built in 1998 and substantially renovated between 2019 and 2021. This summary
@@ -122,6 +135,33 @@ Chunks fall on the document's own paragraphs. A PDF stores glyphs at
122
135
  coordinates, not paragraphs, so the extractor rebuilds the breaks from the line
123
136
  geometry before the chunker sees the page.
124
137
 
138
+ `backdraft read <slug>` on its own is the table of contents: one line per page
139
+ for a source that paginates, and — for a web page, which has none — one line per
140
+ chunk, under the name the page gave itself in its `<title>`. That list is the map
141
+ you read before deciding whether to spend 34,000 characters on the page.
142
+
143
+ And a page read will not spend all 34,000 of them unasked. One read shows
144
+ 12,000 characters by default — 200 rows of a sheet — which every ordinary page
145
+ and small range fits inside, so the output above is what it always was. A page
146
+ that runs past it stops on a chunk boundary and says so:
147
+
148
+ ```console
149
+ $ backdraft read franklin-county p1
150
+ # franklin-county p1 (page 1 of 1)
151
+
152
+ [bd:franklin-county:p1.c1:d9b5]
153
+ Jump to content
154
+ …
155
+ [Showing 0-11531 of 34031 chars. Continue with: backdraft read franklin-county p1 --offset 11531]
156
+ ```
157
+
158
+ That last line is the whole of the contract: how much you got, how much there
159
+ is, and the command that continues where this one stopped — which is also how
160
+ you read a long page in one call, by passing the total it names as `--limit`.
161
+ A read that prints no such line showed you the whole page. The cut falls between
162
+ chunks and never inside one, so every token you are handed still names text you
163
+ were shown all of.
164
+
125
165
  Search results are citable too, no page read required to get an anchor:
126
166
 
127
167
  ```console
@@ -331,7 +371,9 @@ Every surface names a fetched source by its page. The filename you never see —
331
371
  exists on nobody's disk, so `ingest`, `ls`, `backdraft read`, the artifact and
332
372
  the `## References` section `bind --bound` writes all show the URL in its place
333
373
  rather than beside it: two names for one thing would let the invented one look
334
- authoritative.
374
+ authoritative. The artifact goes one further and calls the source by its
375
+ `<title>` where the page declared one, because that is the only name in the
376
+ record the source chose for itself.
335
377
 
336
378
  The origin travels into the artifact too. A claim citing a fetched page shows
337
379
  the URL as a link on its receipt, with the date the bytes were taken. That
@@ -59,13 +59,26 @@ link* — `?oldid=` serves one revision's bytes forever — because a citation i
59
59
  a live article reports `drifted` the moment somebody edits it, and the quoted
60
60
  sentence would no longer be the sentence there.
61
61
 
62
+ `--slug` is there because that URL's default name would be
63
+ `en-wikipedia-org-index` — its last segment is `/index.php`, a handler rather
64
+ than a page, so the host stands in — and a slug is permanent once tokens carry
65
+ it. `backdraft ingest <source> --dry-run` is how to see that before choosing:
66
+ it prints the slug and media type each source would take and stops, fetching
67
+ nothing and writing nothing, and it says when a source is already ingested or
68
+ when the name it wants is taken.
69
+
62
70
  Every ingest line ends with how much text came out, and says which of three
63
71
  things happened: a document created, a **new generation** of one whose bytes
64
72
  moved — which is when citations into the previous snapshot start reporting
65
73
  `drifted` — or `unchanged`, a no-op because re-running would reproduce what is
66
74
  already there. When almost no text came out, a note names the likely cause (a
67
75
  scan with no text layer, a page behind a login) and what to do; it is a note at
68
- exit 0, because a thin snapshot is still a real one.
76
+ exit 0, because a thin snapshot is still a real one. That source keeps the mark
77
+ afterwards — `backdraft read`'s document list, its table of contents and
78
+ `backdraft ls` end its row with `little text: N chars` — because a registry
79
+ travels with the project folder and whoever writes against it is usually not
80
+ whoever ingested it. Ordinary sources gain nothing: only the rows worth doubting
81
+ say anything.
69
82
 
70
83
  **Read.** The page arrives with a citable name over each chunk. This is the whole
71
84
  mechanism: what you can cite is exactly what you were shown.
@@ -75,7 +88,7 @@ $ backdraft read t12-summary p1
75
88
  # t12-summary p1 (page 1 of 3)
76
89
 
77
90
  [bd:t12-summary:p1.c1:c2e8]
78
- Bridgeview Commons, Trailing Twelve Month Summary
91
+ Bridgeview Commons — Trailing Twelve Month Summary
79
92
 
80
93
  Property: Bridgeview Commons, 4400 Halsted Avenue, Columbus, OH 43214. 128 units across four three-story
81
94
  garden buildings on 6.2 acres, built in 1998 and substantially renovated between 2019 and 2021. This summary
@@ -95,6 +108,33 @@ Chunks fall on the document's own paragraphs. A PDF stores glyphs at
95
108
  coordinates, not paragraphs, so the extractor rebuilds the breaks from the line
96
109
  geometry before the chunker sees the page.
97
110
 
111
+ `backdraft read <slug>` on its own is the table of contents: one line per page
112
+ for a source that paginates, and — for a web page, which has none — one line per
113
+ chunk, under the name the page gave itself in its `<title>`. That list is the map
114
+ you read before deciding whether to spend 34,000 characters on the page.
115
+
116
+ And a page read will not spend all 34,000 of them unasked. One read shows
117
+ 12,000 characters by default — 200 rows of a sheet — which every ordinary page
118
+ and small range fits inside, so the output above is what it always was. A page
119
+ that runs past it stops on a chunk boundary and says so:
120
+
121
+ ```console
122
+ $ backdraft read franklin-county p1
123
+ # franklin-county p1 (page 1 of 1)
124
+
125
+ [bd:franklin-county:p1.c1:d9b5]
126
+ Jump to content
127
+ …
128
+ [Showing 0-11531 of 34031 chars. Continue with: backdraft read franklin-county p1 --offset 11531]
129
+ ```
130
+
131
+ That last line is the whole of the contract: how much you got, how much there
132
+ is, and the command that continues where this one stopped — which is also how
133
+ you read a long page in one call, by passing the total it names as `--limit`.
134
+ A read that prints no such line showed you the whole page. The cut falls between
135
+ chunks and never inside one, so every token you are handed still names text you
136
+ were shown all of.
137
+
98
138
  Search results are citable too, no page read required to get an anchor:
99
139
 
100
140
  ```console
@@ -304,7 +344,9 @@ Every surface names a fetched source by its page. The filename you never see —
304
344
  exists on nobody's disk, so `ingest`, `ls`, `backdraft read`, the artifact and
305
345
  the `## References` section `bind --bound` writes all show the URL in its place
306
346
  rather than beside it: two names for one thing would let the invented one look
307
- authoritative.
347
+ authoritative. The artifact goes one further and calls the source by its
348
+ `<title>` where the page declared one, because that is the only name in the
349
+ record the source chose for itself.
308
350
 
309
351
  The origin travels into the artifact too. A claim citing a fetched page shows
310
352
  the URL as a link on its receipt, with the date the bytes were taken. That
@@ -32,157 +32,7 @@ best evidence available for what those five should be.
32
32
 
33
33
  ## Now
34
34
 
35
- ### 1. A web page has a name, and `read` shows it the navigation menu instead
36
-
37
- **Intent.** `backdraft read franklin-county` — the demo's own web source —
38
- prints `p1 Franklin County, Ohio - Wikipedia Jump to content Main menu Main
39
- menu move to sidebar hide Navigation - Main page - Contents - Current events -
40
- Random art...`. The table-of-contents line is the one surface that tells an
41
- agent what is on a page before it spends context reading it, and for every web
42
- source it is site chrome. The page's real name is sitting in the extracted text
43
- and in the markup's `<title>`, unused: a fetched page ingested without `--slug`
44
- also titles itself `En Wikipedia Org Index` in the artifact's source list,
45
- because `render/html/text.source_title` falls back to the slug and title-cases
46
- it. Two surfaces need one fact nothing captures. Separately, an HTML source is
47
- a single page, so its table of contents is that one line and does no
48
- navigating at all — for a 34,000-character article the citable unit is the
49
- chunk and nothing lists the chunks.
50
-
51
- **Shape.** Capture the fact once, at extraction: `extract/html.py` already
52
- parses the markup, so the document's `<title>` becomes page or document meta
53
- there, and everything downstream reads it instead of guessing. Then
54
- `gate/reader.py`'s TOC preview (`TOC_PREVIEW_CHARS`, `_preview`) prefers it,
55
- and `render/html/text.source_title` prefers it over the slug fallback for a
56
- document that has one. For the single-page case, `render_toc` lists the page's
57
- chunks with their opening words, so a one-page source's table of contents is
58
- one. **This is display only and must stay that way**: no chunk boundary moves,
59
- no anchor moves, no token changes, and the slug is not derived from the title —
60
- that is the admissibility argument the 2026-08-20 thin-source note made, and
61
- this row must make it again, because the neighbouring rule ("no boilerplate
62
- stripping: a heuristic that changes its mind moves anchors") is about
63
- extraction and is not being touched. A page with no `<title>`, and every
64
- non-HTML source, keeps exactly what it prints today.
65
-
66
- **Acceptance.** In `demo/`, `backdraft read franklin-county` names the county
67
- rather than "Jump to content", and lists the page's chunks. `backdraft read
68
- t12-summary` and `backdraft read underwriting-model` are byte-identical to
69
- today's — pin both. `backdraft bind memo.md --session s-bridgeview --check
70
- value-trace,overlap` still reports 17 resolved and the same one unresolved,
71
- with the same tokens: mint them before and after and diff. An HTML fixture with
72
- no `<title>` falls back to what it prints now. `demo/walkthrough.md`'s read
73
- blocks and `README.md`'s show the real output. DESIGN row.
74
-
75
- **Size.** Three days.
76
-
77
- ### 2. A page read has no budget and no closing line
78
-
79
- **Intent.** `backdraft read franklin-county p1` prints 36,442 characters and
80
- stops, with nothing to say how much that was or whether it was all of it — it
81
- ends mid-navigation on `49 languages Add topic`, which reads exactly like a
82
- truncation and is not one. `--limit` defaults to "all of them", so the
83
- continuation hint this repo is proud of — `[Showing 0-464 of 3115 chars.
84
- Continue with: …]` — only ever appears to a caller who already knew to pass
85
- `--limit`. The gate is the one surface an agent is *required* to use, which
86
- makes the default the case that matters: one `read` can spend a large fraction
87
- of a context window with no warning, and the agent cannot tell a complete page
88
- from a cut one. `search` caps at 20 by default; `read` caps at nothing. This is
89
- the same silent-cap family as the queued `search` item, from the other end — an
90
- uncapped read that looks capped, rather than a capped search that looks
91
- complete.
92
-
93
- **Shape.** `gate/reader.py`'s page rendering and `gate/cli.py`'s `--limit`. The
94
- decision to make and write down is a default budget versus staying unbounded
95
- and always closing with the size and the continuation line; prefer whichever
96
- keeps a small page byte-identical, since most pages are small and their output
97
- is a contract. Whatever lands, a read that shows part of a page must close by
98
- naming the exact command that continues it, as the existing hint does. One
99
- constraint to state and test rather than discover: the window slices by
100
- characters and a chunk token stands above its chunk, so a cut must not leave a
101
- token over a chunk the reader only half received — either the window respects
102
- chunk boundaries or the closing line says the last chunk is partial. Do not add
103
- an offset story to `search` here; that is the queued item's ground.
104
-
105
- **Acceptance.** `backdraft read t12-summary p1` in `demo/` is byte-identical to
106
- what it prints today — pin it. `backdraft read franklin-county p1` closes by
107
- saying how much of the page it showed and how to see the rest, and running the
108
- command it names continues where it stopped. No read output ever shows a token
109
- above a chunk it did not finish. `demo/walkthrough.md`, `README.md`,
110
- `site/llms.txt` and `skills/backdraft/SKILL.md` show the real output and tell
111
- the agent to continue rather than assume it saw the page. DESIGN row.
112
-
113
- **Size.** Two days.
114
-
115
- ### 3. The thin-source signal exists only in the ingest that printed it
116
-
117
- **Intent.** 2026-08-20 gave `ingest` a character count and a `note: little text
118
- extracted` naming the likely cause — the signal that a source is a shell and
119
- must not be cited. Both are printed once and then gone: nothing stores the
120
- count, and `ls`, the gate's document list and a document's table of contents
121
- say nothing about it. The agent that writes is usually not the process that
122
- ingested — the skill says to ingest everything up front and then read, and a
123
- registry travels with the project folder — so the one signal that matters is
124
- the one an agent is least likely to be present for. Off `backdraft read`'s
125
- list, a 34,000-character article and a 60-character login wall are the same
126
- row.
127
-
128
- **Shape.** The count is derivable from the current extraction
129
- (`sum(len(page.text))`), so this is a reporting change rather than a storage
130
- one unless measurement says otherwise. One owner, shared between `cli.py`'s
131
- ingest line and `gate/reader.py`'s list and headline, the way `unit` and
132
- `source_name` already are — the point of this item is that three surfaces give
133
- one answer, so a second implementation of the count would defeat it. Display
134
- only, as the ingest note is: no token, anchor or status derives from it. Hold
135
- the byte-identity rule the source-naming work established — a registry of
136
- ordinary sources must not gain a column for this, so mark only the sources that
137
- are thin rather than annotating every row.
138
-
139
- **Acceptance.** Ingest a login-wall HTML file beside a real source: `backdraft
140
- read` and `backdraft ls` mark the thin one and say nothing new about the other,
141
- and `backdraft read <thin-slug>` says it in the headline too. A registry with
142
- no thin source prints what it prints today, byte for byte — pinned, including
143
- `demo/`'s three sources. `THIN_SOURCE_CHARS` is read from one place.
144
- `skills/backdraft/SKILL.md` and `site/llms.txt` tell the agent that the
145
- document list is where it learns this, not only the ingest it may not have run.
146
-
147
- **Size.** Two days.
148
-
149
- ### 4. What a URL will be called, before the answer is permanent
150
-
151
- **Intent.** Three docs now tell an agent to pass `--slug` when it ingests a URL,
152
- because a slug is permanent once tokens carry it and the default may name a
153
- site rather than a page. None of them lets the agent find out what the default
154
- would be. `fetch.filename_for` settles the answer from the address alone,
155
- before any bytes exist — that is the 2026-08-19 row's own words — so the
156
- question is cheap and answerable, and today the only way to ask it is to ingest
157
- and live with the result. An agent that guesses wrong has written a name into
158
- every token of the document, and the fix is re-ingesting under a new slug and
159
- rewriting the draft.
160
-
161
- **Shape.** Small and read-only. The natural home is beside the thing it
162
- predicts: a flag on `ingest` that resolves each source to the slug and media
163
- type it would take and prints them without fetching, writing or minting
164
- anything, or a sibling command if a flag on a writing verb reads badly — pick
165
- one and say why in the DESIGN row, since "a verb that does not do its verb" is
166
- the objection to answer. For a URL the answer comes from `fetch.filename_for`
167
- plus `registry.slug_for` and needs no network; the media type is the honest
168
- gap, because only the served content type settles it, and the output must say
169
- so rather than guessing. For a file, both are already knowable. Existing
170
- collisions matter: a slug already taken must be reported as taken, since that is
171
- what turns a predicted `index` into a real `index-2`.
172
-
173
- **Acceptance.** Against `demo/`, asking about
174
- `https://en.wikipedia.org/w/index.php?title=Franklin_County,_Ohio&oldid=1367935775`
175
- prints `en-wikipedia-org-index` and does not create a document, open a socket,
176
- or touch the ledger — check the registry's document count and `ls` before and
177
- after. Asking about a path prints the slug the file would take. Asking about a
178
- source whose slug is already in the registry says so. `README.md`,
179
- `site/docs.html`, `site/llms.txt` and `skills/backdraft/SKILL.md` name it where
180
- they currently say "pass `--slug`", so the advice comes with the way to check
181
- it.
182
-
183
- **Size.** One day.
184
-
185
- ### 5. A calling agent parses prose to find out what happened
35
+ ### 1. A calling agent parses prose to find out what happened
186
36
 
187
37
  **Intent.** `bind` and `verify` are the two commands whose *output* is the
188
38
  product — the exit code says clean or not, and everything actionable is in the
@@ -223,7 +73,7 @@ relay the human report to the user.
223
73
 
224
74
  **Size.** Two to three days.
225
75
 
226
- ### 6. An artifact you were sent cannot be checked against a registry you have
76
+ ### 2. An artifact you were sent cannot be checked against a registry you have
227
77
 
228
78
  **Intent.** `verify`'s second tier runs only where a `.backdraft/` is
229
79
  discoverable from cwd, and the reason is good: an artifact is a file people
@@ -259,7 +109,7 @@ runs only in the project it was bound in.
259
109
 
260
110
  **Size.** One day.
261
111
 
262
- ### 7. A claim that straddles a chunk boundary gets one token instead of two
112
+ ### 3. A claim that straddles a chunk boundary gets one token instead of two
263
113
 
264
114
  **Intent.** `skills/backdraft/SKILL.md` tells the writing agent that "a claim
265
115
  that spans two chunks needs both tokens, not the nearest one" — a correct
@@ -294,7 +144,7 @@ one" with the surface that now says which both are.
294
144
 
295
145
  **Size.** Two to three days.
296
146
 
297
- ### 8. A re-ingested source strands citations one at a time
147
+ ### 4. A re-ingested source strands citations one at a time
298
148
 
299
149
  **Intent.** This is DESIGN.md's oldest Open line — "re-bind/orphan pass on
300
150
  re-ingest of changed docs (chunk ordinal drift)" — and the week that taught
@@ -336,7 +186,7 @@ extraction and ledger counts are identical before and after.
336
186
 
337
187
  **Size.** Three days.
338
188
 
339
- ### 9. What this install can do, said before a verb needs it
189
+ ### 5. What this install can do, said before a verb needs it
340
190
 
341
191
  **Intent.** backdraft degrades rather than fails, which is right, and the price
342
192
  is that its capabilities are discovered one at a time at the moment each is
@@ -373,6 +223,208 @@ run in an unfamiliar environment.
373
223
 
374
224
  **Size.** Two days.
375
225
 
226
+ ### 6. `bind` never says which ledger it judged `not_shown` against
227
+
228
+ **Intent.** `bind`'s report names the mode, the claim and citation counts, every
229
+ status, every check that ran and every failure — everything except the one input
230
+ that decides `not_shown`. The session is resolved silently by
231
+ `cli_context.resolve_session` (flag, then `BACKDRAFT_SESSION`, then the default),
232
+ so `backdraft bind memo.md` in `demo/` with nothing exported reports `not_shown:
233
+ 1` off the shared default ledger and says nothing about it. That is the weakest
234
+ form the check has: the 2026-08-31 row established that the default session
235
+ accumulates across every run in a registry, so a `resolved` there can mean "some
236
+ earlier run read that page" rather than "this writer did". The row fixed it at
237
+ `session show`, the command that reports the ledger, and left `bind`, the
238
+ command that acts on it. The recipient inherits the gap: the record carries
239
+ `session_id` and `spec/artifact.md` calls it "the ledger session, when the run
240
+ had one", so a reader holding a record that says `"session_id": "default"` has
241
+ the fact and no way to know it weakens every `not_shown` and every `resolved`
242
+ beside it.
243
+
244
+ **Shape.** `bind/cli.py`'s report header, and `spec/artifact.md`. Name the
245
+ session on the summary line, and where it is the default one, close with
246
+ `gate.reader.DEFAULT_SESSION_NOTE` — imported, not re-written, which is the whole
247
+ point of the item: two wordings for one cost is how the `session show` note and
248
+ this one drift apart. Keyed on the resolved id rather than on which rule supplied
249
+ it, exactly as `session show` keys it, so an explicit `--session default` is
250
+ named too. Then say it in the format: `spec/artifact.md`'s `session_id` row and
251
+ the legend's `not_shown` line must tell a reader what a default session means for
252
+ the status, since the record travels and the CLI does not. That is a legend
253
+ change, so goldens and the demo regenerate — the 2026-08-24 trade, taken twice
254
+ before. Not the ground of "A calling agent parses prose to find out what
255
+ happened": that item makes the report machine-readable, this one adds a fact the
256
+ report does not currently carry in any form.
257
+
258
+ **Acceptance.** In `demo/`, `backdraft bind memo.md` with no session and no
259
+ `BACKDRAFT_SESSION` names `default` and prints the note; `--session s-bridgeview`
260
+ names that id and prints no note; `--session default` prints the note. Below the
261
+ header the two runs are byte-identical. A test asserts the printed note is
262
+ `gate.reader.DEFAULT_SESSION_NOTE` by constant rather than by substring, so a
263
+ second copy cannot pass. `backdraft verify` on a record bound in the default
264
+ session says so from the record alone, with no registry present. Golden sidecars
265
+ and `demo/memo.backdraft.html`/`site/demo.html` regenerate together and stay
266
+ byte-identical to each other. `README.md`, `site/llms.txt`,
267
+ `skills/backdraft/SKILL.md` and `skills/backdraft-artifact/SKILL.md` follow.
268
+ DESIGN row.
269
+
270
+ **Size.** Two days.
271
+
272
+ ### 7. A withdrawn source is invisible, including to the person looking for it
273
+
274
+ **Intent.** `forget` withdraws a source from every surface that offers one, which
275
+ is right, and the result is that nothing lists what was withdrawn. `ls` says `no
276
+ documents ingested` in a registry holding two withdrawn documents — true about
277
+ sources, false about the registry, and the wording admits nothing. The three
278
+ places a withdrawn document still appears are `session show`'s mark, the JSON
279
+ export, and the unknown-slug error added 2026-09-04; none of them is where
280
+ somebody looks. So an agent handed a project, meeting a citation that binds
281
+ `unresolved — withdrawn from the registry on ...`, can learn that one source went
282
+ and cannot learn what else did. The documented undo makes it worse: re-ingesting
283
+ the file is the way back, and the path to re-ingest lives on the document, which
284
+ is exactly what no surface will show once it is out of the list.
285
+
286
+ **Shape.** `cli.list_documents`, off `documents(include_withdrawn=True)`, which
287
+ already exists. Reuse `registry.withdrawn_reason` for the date and
288
+ `gate.WITHDRAWN_HINT` for the way back — both are single owners and this must not
289
+ mint a fourth wording for a withdrawal. The decision to make and write down is
290
+ whether this is a flag on `ls` or a block printed under it when the registry has
291
+ any: the objection to answer is that `ls` is the readable set, so anything that
292
+ widens it must not make a withdrawn source look available — mark every row and
293
+ never sort a withdrawn one among the live ones. The gate's own document list is
294
+ out of scope and must not change: the gate serves sources, and a withdrawn source
295
+ is not one. Hold the byte-identity rule the source-naming work established — a
296
+ registry with nothing withdrawn prints exactly what it prints today, everywhere.
297
+
298
+ **Acceptance.** In a registry with two documents, one withdrawn, `ls`'s default
299
+ output is byte-identical to the same registry with only the live document — pin
300
+ it. The withdrawn view names the slug, the date it went and the exact `backdraft
301
+ ingest <path>` that brings it back, asserted against `WITHDRAWN_HINT` by
302
+ constant. A registry where everything has been withdrawn no longer reports itself
303
+ empty without qualification. `backdraft read` and the gate's list are unchanged,
304
+ pinned. `README.md`'s "Forgetting a source", `site/docs.html`, `site/llms.txt`
305
+ and `skills/backdraft/SKILL.md` say where to look. DESIGN row.
306
+
307
+ **Size.** One day.
308
+
309
+ ### 8. `--slug` is dropped without a word when the document is already there
310
+
311
+ **Intent.** `Registry.ingest`'s docstring says "`slug` is honoured only when the
312
+ document is new — a slug is stable once assigned", which is the right rule and is
313
+ stated nowhere a caller reads. `backdraft ingest report.pdf --slug q4-report`
314
+ against a `report.pdf` already in the registry prints the old slug, exits 0, and
315
+ never mentions that the flag did nothing; the same is true of a URL re-fetch,
316
+ where the fallback slug the docs warn about is exactly what the caller was trying
317
+ to replace. This is the one input `ingest` accepts and does not report on, in the
318
+ week it learned to report everything else it did — `unchanged`, `new generation`,
319
+ `restored`, thin extractions, and every source that never landed. It is not
320
+ `ingest --dry-run`, which predicts what a source would be called *before* the
321
+ ingest and already says there that `--slug` would not rename a document that has
322
+ one; this is the ingest itself saying what it did with the flag it was handed.
323
+
324
+ **Shape.** `cli.ingest`, in the shape `_outcome_note` and the grouped notes
325
+ already use — a mark on the source's line or one grouped note, matching how
326
+ `restored` was added rather than inventing a third reporting shape. No schema
327
+ change and no registry change: the CLI holds the requested slug and the returned
328
+ `Ingested` carries the assigned one, so the comparison is a string equality in
329
+ the layer that already owns the reporting. Say the next step, which fd8d00f made
330
+ the rule for this command: the slug is permanent because every token carries it,
331
+ so the honest options are keep it, or `forget` the document and ingest the file
332
+ under the new slug as a *new* document — which strands every token minted under
333
+ the old one, and the note must name that cost rather than recommending it. Exit 0
334
+ throughout: getting back the document you already had is not a failure.
335
+
336
+ **Acceptance.** Ingest a file, then ingest it again with `--slug something-else`:
337
+ the run says the slug was not applied, names the slug the document actually has,
338
+ names what re-slugging would cost, and exits 0. Same for a URL re-fetched with a
339
+ `--slug` it did not have the first time. A `--slug` on a genuinely new document,
340
+ and every ingest with no `--slug` at all, is byte-identical to today — pin both,
341
+ including `demo/`'s three sources. `README.md`, `site/llms.txt` and
342
+ `skills/backdraft/SKILL.md` say a slug is permanent where they currently say to
343
+ pass `--slug`. DESIGN row.
344
+
345
+ **Size.** One day.
346
+
347
+ ### 9. `verify` cannot re-check the one status only the ledger can settle
348
+
349
+ **Intent.** `verify`'s source tier re-resolves every token and reports the
350
+ statuses "as `bind` would", with one gap the code names out loud: `not_shown`
351
+ cannot appear, because it is a fact about a ledger session and verify opens none
352
+ (`render/cli.py`'s `_against_sources`). So a record that says a claim cited
353
+ something its author never read comes back `resolved` from the tier that is
354
+ supposed to be the stronger check, and the strongest claim this product makes —
355
+ that the set of citable tokens is exactly the set the gate emitted — is the one
356
+ claim a recipient holding the registry cannot re-check. That recipient is the
357
+ `backdraft-artifact` skill's whole case: they have the artifact, they have the
358
+ project, and the check they most want is the one that says the author read it.
359
+
360
+ **Shape.** `render/cli.py`'s source tier plus one registry read. The record
361
+ already carries `session_id`, and `Registry.was_shown(session_id, token)` already
362
+ answers the question one token at a time — that is the call `bind` makes, so a
363
+ status printed here stays the status a re-bind would print. The standing
364
+ objection is in the docstring and must be answered rather than overridden:
365
+ "read-only, it opens no session and mints nothing, which is what separates it
366
+ from `backdraft show`". Reading a ledger is not minting into one, and the session
367
+ read is the one the record names rather than one verify chose — so
368
+ `ensure_session` must not be called, a session id absent from the registry is
369
+ reported as absent rather than created, and a `session_id` of `null` is reported
370
+ as a run that had none. Nothing about `show`'s minting changes. The `sources:`
371
+ line gains the status; the existing counts must keep meaning what they mean.
372
+
373
+ **Acceptance.** In `demo/`, bind a memo under a session that was never shown one
374
+ of its citations, render, then `backdraft verify` in the project: the source tier
375
+ reports that citation `not_shown` and says which session it read. Verified from
376
+ outside the project, the tier does not run and the output is byte-identical to
377
+ today. A record whose `session_id` names no session in this registry says so and
378
+ does not create it — assert the registry's session count before and after. A
379
+ record with `session_id: null` says the run had no session. Every existing verify
380
+ output where the ledger agrees with the record is byte-identical, pinned,
381
+ including `demo/`'s. `spec/artifact.md` § Checking an artifact,
382
+ `skills/backdraft-artifact/SKILL.md`, `site/llms.txt` and `README.md` follow.
383
+ DESIGN row.
384
+
385
+ **Size.** Two to three days.
386
+
387
+ ### 10. `bind` and `verify` name the failure and not the move
388
+
389
+ **Intent.** 2026-09-01 made `ingest`'s failures say what to do as well as what
390
+ went wrong, on the argument that a calling agent reads the reason and acts on it.
391
+ `bind` and `verify` are the two commands whose output *is* the product, and
392
+ neither says a next step anywhere: `! not_shown: <token> — <claim> @<offset>` is
393
+ a complete diagnosis and a blank instruction. The move differs sharply per status
394
+ and is not guessable — `not_shown` clears by showing the token, `unresolved`
395
+ needs a search and a new token or an uncited claim, `drifted` needs both snippets
396
+ read and a judgement, `malformed` is an href to fix, and a `receipt:` finding
397
+ means the artifact was edited rather than the sources moved. Today that mapping
398
+ lives only in `skills/backdraft/SKILL.md`, which means it works for an agent
399
+ running the skill and for nobody else — a hook, a CI job, a person, or any agent
400
+ that reached the command another way.
401
+
402
+ **Shape.** A closing block on both commands, one line per status actually
403
+ present, naming the exact command that addresses it — the shape `ingest`'s
404
+ grouped notes already use, and never a hint appended to each of eighteen line
405
+ items. One owner for the mapping, imported by `bind/cli.py` and
406
+ `render/cli.py` rather than written twice; the status set is the artifact
407
+ format's and closed, so the mapping is total and a test asserts every
408
+ `CitationStatus` has a line. `verify` adds the `receipt` case, which is not a
409
+ citation status and is the finding that means something categorically different.
410
+ Display only: no exit code moves, no status moves, no record field is added — a
411
+ run that came out clean prints exactly what it prints today. Not "A calling agent
412
+ parses prose to find out what happened", which gives the same two commands a
413
+ machine-readable payload; that item serves a caller
414
+ that parses, this one serves the caller that reads, and both are wanted because
415
+ the human report is what gets relayed to the user.
416
+
417
+ **Acceptance.** In `demo/`, `backdraft bind memo.md --session s-bridgeview
418
+ --check value-trace,overlap` closes by naming `backdraft search` for its one
419
+ `unresolved`, and says nothing about the statuses it did not produce. A bind that
420
+ comes out clean is byte-identical to today, as is a clean `verify` — pin both. A
421
+ test enumerates `CitationStatus` and asserts each has a line and a command that
422
+ `backdraft --help` lists. `verify` on an artifact with one edited snippet says
423
+ the file was changed rather than the sources. `demo/walkthrough.md`'s bind and
424
+ verify blocks show the real output. DESIGN row.
425
+
426
+ **Size.** Two days.
427
+
376
428
  ## Parked
377
429
 
378
430
  Deliberately not queued, each with the reason, so picking one up starts from