backdraft 0.7.0__tar.gz → 0.8.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 (166) hide show
  1. {backdraft-0.7.0 → backdraft-0.8.0}/.claude-plugin/plugin.json +1 -1
  2. {backdraft-0.7.0 → backdraft-0.8.0}/DESIGN.md +4 -2
  3. {backdraft-0.7.0 → backdraft-0.8.0}/PKG-INFO +36 -15
  4. {backdraft-0.7.0 → backdraft-0.8.0}/README.md +35 -14
  5. backdraft-0.8.0/ROADMAP.md +340 -0
  6. {backdraft-0.7.0 → backdraft-0.8.0}/SPEC.md +7 -3
  7. {backdraft-0.7.0 → backdraft-0.8.0}/demo/memo.backdraft.html +162 -85
  8. {backdraft-0.7.0 → backdraft-0.8.0}/demo/memo.md +14 -1
  9. {backdraft-0.7.0 → backdraft-0.8.0}/demo/walkthrough.md +100 -28
  10. {backdraft-0.7.0 → backdraft-0.8.0}/pyproject.toml +1 -1
  11. {backdraft-0.7.0 → backdraft-0.8.0}/site/demo.html +162 -85
  12. {backdraft-0.7.0 → backdraft-0.8.0}/site/docs.html +33 -8
  13. {backdraft-0.7.0 → backdraft-0.8.0}/site/llms.txt +22 -7
  14. {backdraft-0.7.0 → backdraft-0.8.0}/skills/backdraft/SKILL.md +36 -11
  15. {backdraft-0.7.0 → backdraft-0.8.0}/skills/backdraft-artifact/SKILL.md +5 -2
  16. {backdraft-0.7.0 → backdraft-0.8.0}/skills/backdraft-backfill/SKILL.md +5 -3
  17. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/cli.py +116 -14
  18. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/fetch.py +59 -6
  19. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/gate/__init__.py +2 -0
  20. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/gate/reader.py +52 -6
  21. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/registry/__init__.py +8 -0
  22. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/registry/store.py +73 -5
  23. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/html/text.py +8 -1
  24. {backdraft-0.7.0 → backdraft-0.8.0}/tests/gate/fake_registry.py +10 -1
  25. {backdraft-0.7.0 → backdraft-0.8.0}/tests/gate/test_gate_reader.py +89 -0
  26. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_cli.py +189 -6
  27. backdraft-0.8.0/tests/test_docs.py +133 -0
  28. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_fetch.py +50 -0
  29. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_html.py +14 -1
  30. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_ingest_url.py +95 -8
  31. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_registry.py +38 -2
  32. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_reingest.py +12 -2
  33. {backdraft-0.7.0 → backdraft-0.8.0}/uv.lock +1 -1
  34. backdraft-0.7.0/ROADMAP.md +0 -316
  35. backdraft-0.7.0/tests/test_docs.py +0 -44
  36. {backdraft-0.7.0 → backdraft-0.8.0}/.claude-plugin/marketplace.json +0 -0
  37. {backdraft-0.7.0 → backdraft-0.8.0}/.github/workflows/ci.yml +0 -0
  38. {backdraft-0.7.0 → backdraft-0.8.0}/.github/workflows/release.yml +0 -0
  39. {backdraft-0.7.0 → backdraft-0.8.0}/.github/workflows/site.yml +0 -0
  40. {backdraft-0.7.0 → backdraft-0.8.0}/.gitignore +0 -0
  41. {backdraft-0.7.0 → backdraft-0.8.0}/.python-version +0 -0
  42. {backdraft-0.7.0 → backdraft-0.8.0}/LICENSE +0 -0
  43. {backdraft-0.7.0 → backdraft-0.8.0}/assets/backdraft-mark.svg +0 -0
  44. {backdraft-0.7.0 → backdraft-0.8.0}/demo/generate_sources.py +0 -0
  45. {backdraft-0.7.0 → backdraft-0.8.0}/demo/sources/t12-summary.pdf +0 -0
  46. {backdraft-0.7.0 → backdraft-0.8.0}/demo/sources/underwriting-model.xlsx +0 -0
  47. {backdraft-0.7.0 → backdraft-0.8.0}/site/index.html +0 -0
  48. {backdraft-0.7.0 → backdraft-0.8.0}/site/privacy.html +0 -0
  49. {backdraft-0.7.0 → backdraft-0.8.0}/spec/artifact.md +0 -0
  50. {backdraft-0.7.0 → backdraft-0.8.0}/spec/chunking.md +0 -0
  51. {backdraft-0.7.0 → backdraft-0.8.0}/spec/tokens.md +0 -0
  52. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/__init__.py +0 -0
  53. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/__main__.py +0 -0
  54. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/bind/__init__.py +0 -0
  55. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/bind/binder.py +0 -0
  56. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/bind/cli.py +0 -0
  57. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/bind/evidence.py +0 -0
  58. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/bind/verify/__init__.py +0 -0
  59. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/bind/verify/base.py +0 -0
  60. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/bind/verify/entail.py +0 -0
  61. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/bind/verify/overlap.py +0 -0
  62. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/bind/verify/recompute.py +0 -0
  63. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/bind/verify/value_trace.py +0 -0
  64. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/cli_context.py +0 -0
  65. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/credentials.py +0 -0
  66. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/__init__.py +0 -0
  67. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/base.py +0 -0
  68. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/csv.py +0 -0
  69. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/docx.py +0 -0
  70. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/html.py +0 -0
  71. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/image.py +0 -0
  72. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/pdf_text.py +0 -0
  73. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/pptx.py +0 -0
  74. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/sheet.py +0 -0
  75. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/snapshots.py +0 -0
  76. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/text.py +0 -0
  77. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/vlm.py +0 -0
  78. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/vlm_settings.py +0 -0
  79. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/xls.py +0 -0
  80. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/extract/xlsx.py +0 -0
  81. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/gate/cli.py +0 -0
  82. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/gate/searcher.py +0 -0
  83. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/kernel/__init__.py +0 -0
  84. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/kernel/artifact.py +0 -0
  85. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/kernel/chunking.py +0 -0
  86. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/kernel/claims.py +0 -0
  87. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/kernel/errors.py +0 -0
  88. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/kernel/hashing.py +0 -0
  89. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/kernel/model.py +0 -0
  90. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/kernel/tokens.py +0 -0
  91. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/registry/ledger.py +0 -0
  92. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/registry/schema.sql +0 -0
  93. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/__init__.py +0 -0
  94. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/_text.py +0 -0
  95. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/cli.py +0 -0
  96. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/footnotes.py +0 -0
  97. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/html/__init__.py +0 -0
  98. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/html/assets.py +0 -0
  99. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/html/components.py +0 -0
  100. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/html/fmt.py +0 -0
  101. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/html/fmt_vectors.py +0 -0
  102. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/html/page.py +0 -0
  103. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/markdown.py +0 -0
  104. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/placement.py +0 -0
  105. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/sidecar.py +0 -0
  106. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/theme.py +0 -0
  107. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/themes/default.toml +0 -0
  108. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/themes/press.toml +0 -0
  109. {backdraft-0.7.0 → backdraft-0.8.0}/src/backdraft/render/themes/slate.toml +0 -0
  110. {backdraft-0.7.0 → backdraft-0.8.0}/tests/conftest.py +0 -0
  111. {backdraft-0.7.0 → backdraft-0.8.0}/tests/conftest_registry.py +0 -0
  112. {backdraft-0.7.0 → backdraft-0.8.0}/tests/conftest_render.py +0 -0
  113. {backdraft-0.7.0 → backdraft-0.8.0}/tests/css_util.py +0 -0
  114. {backdraft-0.7.0 → backdraft-0.8.0}/tests/fakes.py +0 -0
  115. {backdraft-0.7.0 → backdraft-0.8.0}/tests/gate/conftest.py +0 -0
  116. {backdraft-0.7.0 → backdraft-0.8.0}/tests/gate/test_gate_cli.py +0 -0
  117. {backdraft-0.7.0 → backdraft-0.8.0}/tests/gate/test_gate_ledger.py +0 -0
  118. {backdraft-0.7.0 → backdraft-0.8.0}/tests/gate/test_gate_searcher.py +0 -0
  119. {backdraft-0.7.0 → backdraft-0.8.0}/tests/gate/test_reader_cells.py +0 -0
  120. {backdraft-0.7.0 → backdraft-0.8.0}/tests/gate/test_reader_show.py +0 -0
  121. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/bind/backfill.bound.md +0 -0
  122. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/bind/frontwalk.bound.md +0 -0
  123. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/chunking/long_tail.json +0 -0
  124. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/chunking/long_tail.txt +0 -0
  125. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/chunking/many_small.json +0 -0
  126. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/chunking/many_small.txt +0 -0
  127. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/chunking/mixed.json +0 -0
  128. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/chunking/mixed.txt +0 -0
  129. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/chunking/one_giant.json +0 -0
  130. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/chunking/one_giant.txt +0 -0
  131. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/chunking/short_page.json +0 -0
  132. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/chunking/short_page.txt +0 -0
  133. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/chunking/unsplittable.json +0 -0
  134. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/chunking/unsplittable.txt +0 -0
  135. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/render/backfill.footnotes.md +0 -0
  136. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/render/backfill.sidecar.json +0 -0
  137. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/render/demo.footnotes.md +0 -0
  138. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden/render/demo.sidecar.json +0 -0
  139. {backdraft-0.7.0 → backdraft-0.8.0}/tests/golden_util.py +0 -0
  140. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_bind_binder.py +0 -0
  141. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_bind_cli.py +0 -0
  142. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_bind_evidence.py +0 -0
  143. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_bind_verify.py +0 -0
  144. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_card_sizing.py +0 -0
  145. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_chunking.py +0 -0
  146. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_claims.py +0 -0
  147. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_credentials.py +0 -0
  148. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_extract.py +0 -0
  149. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_extract_html.py +0 -0
  150. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_fmt_parity.py +0 -0
  151. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_footnotes.py +0 -0
  152. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_gate_integration.py +0 -0
  153. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_hashing.py +0 -0
  154. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_invariants.py +0 -0
  155. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_model.py +0 -0
  156. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_placement.py +0 -0
  157. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_records_location.py +0 -0
  158. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_render_cli.py +0 -0
  159. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_render_markdown.py +0 -0
  160. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_sidecar.py +0 -0
  161. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_snapshot_settings.py +0 -0
  162. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_snapshots.py +0 -0
  163. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_theme.py +0 -0
  164. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_tokens.py +0 -0
  165. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_value_trace.py +0 -0
  166. {backdraft-0.7.0 → backdraft-0.8.0}/tests/test_version.py +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "backdraft",
3
3
  "displayName": "Backdraft",
4
- "version": "0.7.0",
4
+ "version": "0.8.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",
@@ -143,6 +143,10 @@ Self-contained HTML (plus the machine-readable sidecar): **quotes + receipts + e
143
143
  | 2026-08-11 | **An extractor declares the `--config` keys it reads, and an undeclared key is an error.** `--config dpy=300` used to exit 0, ingest, and change nothing: the config boundary was the one place this repo's "failures are data, never warn-and-drop" rule did not hold, and nothing — help, docs, `llms.txt` — said which keys existed, so learning them meant reading `extract/vlm.py` and `extract/snapshots.py`. The `Extractor` protocol gains an optional `config_keys: Mapping[str, str]` (key → one-line meaning) and `check_config` validates against it. Validation happens **after selection and inside `Registry.ingest`**, not in the CLI: `auto` picks per file, so only the chosen extractor can say whether `dpi` is a render setting or a typo, and putting it at the registry means every substrate is covered rather than only the one with a `--config` flag. It raises `ExtractionError` rather than the `UsageError` a CLI-side check would have used — `cli_context` imports the registry, so the registry cannot import `UsageError` back — and both are `BackdraftError`, so the guard maps either to exit 1 and the user sees the same line. Each key is declared once by the module that already owns its default and reads it: the page-render budget (`dpi`, `snapshot_quality`, `snapshot_max_height`) in `extract/snapshots.py`, the provider surface (`api_key`, `base_url`, `model`, `timeout`, `retries`) in `extract/vlm_settings.py`, and the extractors compose those by reference. `pdf-text` declares the page-render keys despite reading none of them itself — the snapshot capture that runs beside it at ingest does, and a user passing `dpi` to a text-layer ingest means the images; declaring by *what one flag on one ingest affects* beats declaring by which function happens to call `config.get`. Tradeoff accepted: declaring nothing means accepting nothing, so an out-of-tree extractor that reads config without declaring it starts rejecting keys it used to receive. That is the intended direction — a silently ignored key is the failure being fixed — and the fix is one attribute. The help text that lists the keys is pinned by a test against the declarations, so a new key cannot be added in code and left undocumented. |
144
144
  | 2026-08-12 | **`show` is a gate verb, not a lookup tool.** `backdraft show <token>` answers what a token says — the inverse of minting, for a token that arrived out of an artifact, a draft, or somebody's message, which nothing in the tool could answer before without rebuilding the slug and selector by hand and re-reading a whole page. It therefore *mints what it shows*, exactly as `read` and `search` do: an agent that has seen a snippet through the gate has genuinely read it, so recording it is honest rather than a loophole, and `not_shown` stays true by construction — a status `show` can never produce. That makes a token laundering into a citable one by being looked at, which is the intended behavior and the reason this is in `gate/` and not somewhere neutral. Resolution is bind's path rather than a second reading of what a token means: `kernel.claims.parse_citation` decides `malformed` (so the reserved `bd:calc(...)` form lands identically in both) and `Registry.resolve` decides the rest, which is what makes a status printed here the status bind would print. Two smaller calls. `drifted` prints both snippets *and* mints the anchor standing at the locator now, since that anchor's token is the one worth citing and printing it unrecorded would be the under-minting the gate must never do. And drift does **not** fail the exit code: exit 1 covers `unresolved` and `malformed`, the two statuses where there was nothing to show, because whether a changed source still supports a sentence is bind's question about a document, not a lookup's about a token. Tradeoff accepted: auditing someone else's artifact against a registry now writes ledger rows into the auditor's session, so `skills/backdraft-artifact/SKILL.md` says so and keeps that check reported separately from the artifact's self-checks. |
145
145
  | 2026-08-13 | **`ingest` finishes its list; an unreadable source is data, not the end of the run.** `backdraft ingest a.md broken.pdf c.md` used to ingest `a.md`, raise on `broken.pdf`, and never attempt `c.md` — leaving a half-built registry, a message naming only the first failure, and no way to tell which sources landed from which were never tried, so ingesting a folder meant re-running and inferring the state. The loop now catches `BackdraftError` per source and records `(source, reason)`, so every source is attempted, and prints one `!` line per failure under a count (`2 of 3 sources ingested; 1 failed`) whose two numbers add to the whole list — which is precisely the fact the old behavior could not state. This is principle 4 ("failures are data, never warn-and-drop") applied at the boundary it had not reached, and the same shape the page-snapshot note already uses one level down. Grouping differs deliberately: the snapshot note groups by *reason* because one missing poppler explains every document at once, while this lists by *source*, because the source is what the caller has to go fix. The exit code is unchanged at 1 and `guard` remains the only place a `BackdraftError` becomes one — nothing caught per source is re-raised, and the report exits through `fail` like any other error line, on stderr, so a clean run's stdout is byte-identical to before and that is pinned by a test. Tradeoff accepted: a run that fails partway now leaves *more* in the registry than it used to, so "the command failed" no longer implies "nothing happened". The closing line of the report answers that directly — re-running the same list is safe, because a source already ingested with unchanged bytes, extractor and config is a no-op — which is cheaper for a caller than reconstructing the failed half by diffing `ls` against its own arguments. |
146
+ | 2026-08-17 | **The demo cites a web page at a fixed revision, so the capability is visible and stays `resolved`.** URL sources shipped whole — fetch, snapshot, `url`/`fetched_at`, the artifact's live dated link — and none of it appeared at backdraft.dev/demo.html, because both demo sources were local files and the byte-identity test that proved the artifact change safe was the same fact that kept it invisible. The demo gains a third source: the Wikipedia article for Franklin County, Ohio — the county the fictional property sits in and the one its tax appeal is already pending before — cited for two market figures a T12 cannot supply, the county's population and its renter-occupied share. It is ingested at Wikipedia's **permanent link** (`?oldid=<rev>`) rather than the bare article URL. What that gives up is named: the demo no longer shows a live page changing under a citation, which is the better story and belongs in a `drifted` walkthrough rather than the shop window. What it buys is that the front page does not permanently display `drifted` within days of the next edit — a demo that always shows drift teaches that the tool is broken rather than that the source moved — and an immutable revision plus the rendered "as of" date is what a careful analyst cites anyway. The same reasoning is now advice rather than a one-off: the docs, `llms.txt` and the writing skill tell an agent to prefer a fixed-revision address (`?oldid=`, a DOI, an archived snapshot) and to pass `--slug` with it. `--slug` is load-bearing here, not decoration: a permanent link is served from `/w/index.php`, so the default stem would be `index` and every token in the memo would carry it. That is the queued slug-assignment item's problem and is fixed at the fetch layer there, not worked around here. One renderer change came with it. `source_title` took a source's display name from its filename's stem, which for a fetched page is the staging name `fetch.filename_for` invented — so the article titled itself "Index". Where a document carries a URL the slug is the name instead, which is the 2026-08-06 row's rule ("showing both would let the fictional one look authoritative") applied to the one surface that had kept the invented name. It changes nothing for a page ingested without `--slug`, since the slug is derived from that same stem; it defers to whoever named the document when someone did. |
147
+ | 2026-08-18 | **One rule names a source, and every surface that names one uses it.** A fetched page's `filename` is the temporary file `fetch.filename_for` staged the bytes in — `index.html` for a permanent link, a name on nobody's disk — and the 2026-08-06 row settled what to do about it ("showing both would give a reader two names for one thing and let the fictional one look authoritative") for the artifact alone. The four CLI surfaces that name a document kept the fiction: `ingest`'s line, the gate's document list, its table-of-contents headline, and `ls` — which showed *both*, filename in column two and URL in a fifth field, the exact shape that row rejected. `gate.source_name` is now the single owner, exported the way `gate.unit` is and imported downward by `cli.py`, so a writing agent learns a source's provenance from the surface it already lives on instead of being sent to `ls` by the skill. Display only: the slug, the token and everything a citation resolves through are untouched, which is the 2026-08-05 "provenance, never identity" rule. Two consequences named rather than discovered. `ls` loses a field — a fetched row is four columns, not five — so anything parsing column five stops seeing one; that is the point, since column two was a lie and one true name beats two names of which one is fictional. And in the gate's aligned list a URL *overflows* the name column rather than widening it: the column is sized on filenames alone, so one 80-character address pushes its own row's later columns right instead of pushing every row's past column 100. The cost is a ragged right on fetched rows, accepted because the file rows a mostly-file registry is made of stay byte-identical to what they printed before — pinned by a test, the way the artifact's byte-identity is. `bind`'s unmatched-claim report still names a source by filename: the fix cannot be shared into it without a sideways `bind` → `gate` import, which SPEC § Dependency rule forbids, and duplicating the rule there is the reinvention this row exists to end. |
148
+ | 2026-08-19 | **A slug is assigned by a rule, and the rule is written down.** The Open list carried "slug assignment/collision rules" from the first field trial, and URL sources sharpened it into a live failure: `fetch.filename_for` took the stem from a URL's last path segment, so `https://a.example/reports/2025/index.html` and `https://b.example/docs/index.html` both staged as `index.html` and became `index` and `index-2` — two handles that say which is which to nobody, carried by every token in every document written against them, permanently, since a slug is stable once assigned. The rule now has two clauses and they are stated together for files and URLs. A **file** is named by its filename's stem, kebabed and cut to 32 characters, which is unchanged and was never the problem. A **URL** is named by its last path segment, *except* when that segment names the site's plumbing rather than its page — a directory index or generic handler (`_GENERIC_STEMS`: `index`, `view`, `page`, `show`, and a dozen more), an all-digit id, or no segment at all — in which case the host goes in front of it, minus `www.` and minus the port, so the two pages above become `a-example-index` and `b-example-index`. The common case is deliberately untouched: `https://example.com/reports/q4-2025` is still `q4-2025`, because a rule that made every good name uglier to fix the bad ones would be the wrong trade. `_dedupe`'s `-2` stays, demoted from the normal outcome to the backstop it was meant to be — the host is a *better* stem, not a unique one, and two `index.html` pages on one site still collide. Truncation is trimmed on both cuts now (`slug_for` and `_dedupe`), which is the other half of the Open line: a 32-character cut lands on a hyphen often enough that `q4-report-` was a slug this repo could produce, legal in the grammar and reading like a bug. The compatibility statement, said out loud because nothing else will say it: two registries built on either side of this change disagree about a fetched page's slug, and an artifact's tokens are not portable between them. Existing registries are unaffected, and that is not luck — `_find_document` matches a re-fetch on the URL, never the slug, so re-ingesting a page keeps the slug it already has. `--slug` remains the way to overrule all of this and is still the advice for a fetched page worth citing, because `a-example-index` names a site rather than nothing, which is better and is not good. |
149
+ | 2026-08-20 | **`ingest` says which of its three outcomes happened, and how much text came out.** One line served three different events — a document created, a *new generation* of one whose bytes moved, and a no-op that wrote nothing — so an agent re-ingesting after a fix could not tell whether anything had changed, at exactly the moment it most needed to: a new generation is when citations into the previous snapshot begin resolving to a superseded one and `bind` starts calling them `drifted`. The registry already distinguished all three internally (`_find_document`, `_is_noop`, `_upsert_document`) and threw the answer away, so `ingest` now returns an `Ingested` — a `Document` subclass carrying `outcome`, the shape `SearchResults` established for `search`: Addendum A's pinned `-> Document` still holds, every `.slug` caller and every fake is untouched, and the one consumer that cares reads the extra field. Re-deriving the outcome in the CLI was the alternative and would have meant a second implementation of `_is_noop` living away from the first. The wart is named rather than papered over: a dataclass compares by class, so an `Ingested` never equals the plain `Document` the read side returns for the same row, and the two tests that compared them now compare what persists. The second half is a number: the extracted character count, beside the page count on every line. A scanned PDF with no text layer and a page behind a login both ingest cleanly, exit 0 and print a page count like any success, which is how a shell of a source gets cited — and the writing skill's instruction to notice "thin or empty" gave the agent nothing to notice it with. Under `THIN_SOURCE_CHARS` (200) a note names the likely cause by media type and what to do about it. That threshold is a heuristic, which this repo refuses elsewhere on purpose — the `html` extractor is a parse and not a readability guess, because a heuristic that changes its mind moves anchors. This one is admissible for the reason those are not: it is display only, at exit 0, and no token, anchor, status or byte of the artifact derives from it, so being wrong costs one line of advice and never a citation. `chars` counts sheets too, where `read`'s window unit is rows: this is the extraction's volume, not a window into it. Grouping follows the 2026-08-13 rule — thin sources group by cause, since one missing text layer explains every scan at once and each document's own count is already on its own line, while the failure report still lists by source. |
146
150
 
147
151
  ## Open
148
152
 
@@ -150,8 +154,6 @@ Queued and parked work lives in ROADMAP.md; this list is questions still
150
154
  awaiting a decision:
151
155
 
152
156
  - Re-bind/orphan pass on re-ingest of changed docs (chunk ordinal drift)
153
- - Slug assignment/collision rules (the trailing-hyphen truncation from the
154
- first field trial lives here)
155
157
  - Distribution: published to PyPI as `backdraft` 0.2.0 (2026-07-28); domain
156
158
  backdraft.dev acquired; still open: skill registration, deploying site/
157
159
  - First external user (design-partner validation layer vs eval substrate vs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: backdraft
3
- Version: 0.7.0
3
+ Version: 0.8.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
@@ -67,11 +67,27 @@ documents: 0
67
67
  … plus where settings live and what to do next
68
68
 
69
69
  $ backdraft ingest sources/t12-summary.pdf sources/underwriting-model.xlsx
70
- t12-summary t12-summary.pdf pdf 3 pages
71
- underwriting-model underwriting-model.xlsx xlsx 2 sheets
70
+ t12-summary t12-summary.pdf pdf 3 pages 8865 chars
71
+ underwriting-model underwriting-model.xlsx xlsx 2 sheets 2357 chars
72
72
  note: extracted with pdf-text (the embedded text layer). Glossy or scanned PDFs extract better through a vision model: set BACKDRAFT_VLM_API_KEY in .backdraft/env.
73
+
74
+ $ backdraft ingest "https://en.wikipedia.org/w/index.php?title=Franklin_County,_Ohio&oldid=1367935775" --slug franklin-county
75
+ franklin-county https://en.wikipedia.org/w/index.php?title=Franklin_County,_Ohio&oldid=1367935775 html 1 page 34141 chars
73
76
  ```
74
77
 
78
+ The demo cites that page for market context. It is Wikipedia's *permanent
79
+ link* — `?oldid=` serves one revision's bytes forever — because a citation into
80
+ a live article reports `drifted` the moment somebody edits it, and the quoted
81
+ sentence would no longer be the sentence there.
82
+
83
+ Every ingest line ends with how much text came out, and says which of three
84
+ things happened: a document created, a **new generation** of one whose bytes
85
+ moved — which is when citations into the previous snapshot start reporting
86
+ `drifted` — or `unchanged`, a no-op because re-running would reproduce what is
87
+ already there. When almost no text came out, a note names the likely cause (a
88
+ scan with no text layer, a page behind a login) and what to do; it is a note at
89
+ exit 0, because a thin snapshot is still a real one.
90
+
75
91
  **Read.** The page arrives with a citable name over each chunk. This is the whole
76
92
  mechanism: what you can cite is exactly what you were shown.
77
93
 
@@ -150,12 +166,12 @@ Multiple citations are `;`-separated in one href.
150
166
 
151
167
  ```console
152
168
  $ backdraft bind memo.md --session s-bridgeview --check value-trace,overlap
153
- bound 15 claim(s), 16 citation(s) [frontwalk]
154
- resolved: 15
169
+ bound 17 claim(s), 18 citation(s) [frontwalk]
170
+ resolved: 17
155
171
  unresolved: 1
156
- overlap: pass 11, skip 4
157
- value-trace: pass 15
158
- ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @2900
172
+ overlap: pass 13, skip 4
173
+ value-trace: pass 17
174
+ ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @3629
159
175
  wrote .backdraft/records/memo.backdraft.json
160
176
 
161
177
  $ echo $?
@@ -244,18 +260,23 @@ address.
244
260
 
245
261
  ```console
246
262
  $ backdraft ingest https://example.com/reports/q4-2025
247
- q4-2025 q4-2025.html html 1 page
263
+ q4-2025 https://example.com/reports/q4-2025 html 1 page 18402 chars
248
264
 
249
265
  $ backdraft ls
250
- q4-2025 q4-2025.html html 1 page https://example.com/reports/q4-2025
266
+ q4-2025 https://example.com/reports/q4-2025 html 1 page
251
267
  ```
252
268
 
269
+ Every surface names a fetched source by its page. The filename you never see —
270
+ `q4-2025.html` — is the temporary file the fetch staged the bytes in, and it
271
+ exists on nobody's disk, so `ingest`, `ls`, `backdraft read` and the artifact
272
+ all show the URL in its place rather than beside it: two names for one thing
273
+ would let the invented one look authoritative.
274
+
253
275
  The origin travels into the artifact too. A claim citing a fetched page shows
254
- the URL as a link on its receipt, with the date the bytes were taken, and the
255
- source list names the page rather than the staged filename. That pointer is the
256
- half of citing a web page a frozen receipt cannot answer on its own: the
257
- snippet says what the page said, the link is how a reader asks whether it still
258
- says it.
276
+ the URL as a link on its receipt, with the date the bytes were taken. That
277
+ pointer is the half of citing a web page a frozen receipt cannot answer on its
278
+ own: the snippet says what the page said, the link is how a reader asks whether
279
+ it still says it.
259
280
 
260
281
  What it does not do, stated rather than worked around: JavaScript-rendered
261
282
  pages give you whatever the server sends to a plain GET, pages behind a login
@@ -42,11 +42,27 @@ documents: 0
42
42
  … plus where settings live and what to do next
43
43
 
44
44
  $ backdraft ingest sources/t12-summary.pdf sources/underwriting-model.xlsx
45
- t12-summary t12-summary.pdf pdf 3 pages
46
- underwriting-model underwriting-model.xlsx xlsx 2 sheets
45
+ t12-summary t12-summary.pdf pdf 3 pages 8865 chars
46
+ underwriting-model underwriting-model.xlsx xlsx 2 sheets 2357 chars
47
47
  note: extracted with pdf-text (the embedded text layer). Glossy or scanned PDFs extract better through a vision model: set BACKDRAFT_VLM_API_KEY in .backdraft/env.
48
+
49
+ $ backdraft ingest "https://en.wikipedia.org/w/index.php?title=Franklin_County,_Ohio&oldid=1367935775" --slug franklin-county
50
+ franklin-county https://en.wikipedia.org/w/index.php?title=Franklin_County,_Ohio&oldid=1367935775 html 1 page 34141 chars
48
51
  ```
49
52
 
53
+ The demo cites that page for market context. It is Wikipedia's *permanent
54
+ link* — `?oldid=` serves one revision's bytes forever — because a citation into
55
+ a live article reports `drifted` the moment somebody edits it, and the quoted
56
+ sentence would no longer be the sentence there.
57
+
58
+ Every ingest line ends with how much text came out, and says which of three
59
+ things happened: a document created, a **new generation** of one whose bytes
60
+ moved — which is when citations into the previous snapshot start reporting
61
+ `drifted` — or `unchanged`, a no-op because re-running would reproduce what is
62
+ already there. When almost no text came out, a note names the likely cause (a
63
+ scan with no text layer, a page behind a login) and what to do; it is a note at
64
+ exit 0, because a thin snapshot is still a real one.
65
+
50
66
  **Read.** The page arrives with a citable name over each chunk. This is the whole
51
67
  mechanism: what you can cite is exactly what you were shown.
52
68
 
@@ -125,12 +141,12 @@ Multiple citations are `;`-separated in one href.
125
141
 
126
142
  ```console
127
143
  $ backdraft bind memo.md --session s-bridgeview --check value-trace,overlap
128
- bound 15 claim(s), 16 citation(s) [frontwalk]
129
- resolved: 15
144
+ bound 17 claim(s), 18 citation(s) [frontwalk]
145
+ resolved: 17
130
146
  unresolved: 1
131
- overlap: pass 11, skip 4
132
- value-trace: pass 15
133
- ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @2900
147
+ overlap: pass 13, skip 4
148
+ value-trace: pass 17
149
+ ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @3629
134
150
  wrote .backdraft/records/memo.backdraft.json
135
151
 
136
152
  $ echo $?
@@ -219,18 +235,23 @@ address.
219
235
 
220
236
  ```console
221
237
  $ backdraft ingest https://example.com/reports/q4-2025
222
- q4-2025 q4-2025.html html 1 page
238
+ q4-2025 https://example.com/reports/q4-2025 html 1 page 18402 chars
223
239
 
224
240
  $ backdraft ls
225
- q4-2025 q4-2025.html html 1 page https://example.com/reports/q4-2025
241
+ q4-2025 https://example.com/reports/q4-2025 html 1 page
226
242
  ```
227
243
 
244
+ Every surface names a fetched source by its page. The filename you never see —
245
+ `q4-2025.html` — is the temporary file the fetch staged the bytes in, and it
246
+ exists on nobody's disk, so `ingest`, `ls`, `backdraft read` and the artifact
247
+ all show the URL in its place rather than beside it: two names for one thing
248
+ would let the invented one look authoritative.
249
+
228
250
  The origin travels into the artifact too. A claim citing a fetched page shows
229
- the URL as a link on its receipt, with the date the bytes were taken, and the
230
- source list names the page rather than the staged filename. That pointer is the
231
- half of citing a web page a frozen receipt cannot answer on its own: the
232
- snippet says what the page said, the link is how a reader asks whether it still
233
- says it.
251
+ the URL as a link on its receipt, with the date the bytes were taken. That
252
+ pointer is the half of citing a web page a frozen receipt cannot answer on its
253
+ own: the snippet says what the page said, the link is how a reader asks whether
254
+ it still says it.
234
255
 
235
256
  What it does not do, stated rather than worked around: JavaScript-rendered
236
257
  pages give you whatever the server sends to a plain GET, pages behind a login
@@ -0,0 +1,340 @@
1
+ # Roadmap
2
+
3
+ Sequenced intent. DESIGN.md records what was *decided*; this file records what
4
+ is *queued*, so tabling something is an act of writing it down here rather than
5
+ losing it. Items graduate by getting built and deleted.
6
+
7
+ ## How the queue runs
8
+
9
+ The Now section is an ordered queue written for an implementing agent with no
10
+ session context: each item carries its intent, its shape, and its acceptance
11
+ test. The working agreement is one item at a time, top first, shipped complete
12
+ — code, tests, docs, and a DESIGN.md decision row where the item calls for one
13
+ — then deleted from this file in the same commit. Releases cut Friday, so an
14
+ item that lands mid-week waits on PyPI until the release; nothing ships
15
+ half-done to make a release. An item that turns out bigger than its sizing
16
+ gets split here, not stretched silently.
17
+
18
+ The cadence is daily and the runner is an agent: one item each morning Monday
19
+ through Thursday, landing as a direct commit to `main` — this repo does not
20
+ use pull requests, so a week of commits is the only review surface there is.
21
+
22
+ Friday builds nothing new. It reads that week's diff instead — duplication,
23
+ drift from the repo's own idioms, branches that landed untested, help text and
24
+ error messages that confuse, commands the docs describe wrongly — and lands
25
+ the fixes. Then the release, and only if something landed since the last tag:
26
+ the version moves in both `pyproject.toml` and `.claude-plugin/plugin.json`,
27
+ and publishing the GitHub release is what ships to PyPI. Then this section is
28
+ refilled with five items for the week ahead, drawn against the core — the four
29
+ verbs, the registry, the grammar, the artifact — and against where an agent
30
+ using backdraft actually stumbles. What the maintenance pass just found is the
31
+ best evidence available for what those five should be.
32
+
33
+ ## Now
34
+
35
+ ### 1. `backdraft verify <artifact>`: checking a receipt without the registry
36
+
37
+ **Intent.** `skills/backdraft-artifact/SKILL.md` exists because there is no
38
+ command: it walks an agent through opening a `.backdraft.json` or
39
+ `.backdraft.html`, recomputing each `snippet_sha256`, and reporting claim
40
+ statuses by hand. That is a deterministic check over a self-describing file,
41
+ which is precisely the kind of thing that should not be a prose procedure an
42
+ agent re-implements every time — and doing it by hand is how a recipient's
43
+ check ends up weaker than the producer's. The artifact is the product; nothing in the
44
+ tool reads one back.
45
+
46
+ **Shape.** A new command in `render/cli.py` (which already owns the artifact's
47
+ reader half through `sidecar.read`), taking the artifact or its sidecar.
48
+ Two tiers, and the output must be explicit about which ran. Tier one needs
49
+ nothing but the file: parse it against `kernel/artifact.py`'s format, recompute
50
+ every `snippet_sha256` from the snippet the file carries, and confirm the
51
+ `$format` and the claim/citation counts — this is the check a recipient with
52
+ only the file can make, and it catches an edited artifact. Tier two runs only
53
+ when a registry is discoverable: re-resolve each token through the same path
54
+ `bind` uses and report the closed status set. Exit 0 when everything checked
55
+ passed, 1 on a usage error, and — matching `bind` — 2 when something did not
56
+ verify, so a hook can gate on it. Read-only: it opens no session and mints
57
+ nothing, which is what distinguishes it from `read` and from `backdraft show`
58
+ (landed 2026-08-12), whose whole point is that showing mints — an audit must not
59
+ make its subject citable.
60
+
61
+ **Acceptance.** `backdraft verify demo/memo.backdraft.html` from outside
62
+ `demo/` reports tier one passing and says plainly that no registry was found,
63
+ exiting 0. From inside `demo/` it also re-resolves and reports the one
64
+ unresolved citation, exiting 2. A file with one snippet byte changed fails by
65
+ naming the claim and the hash that did not match. A file that is not an
66
+ artifact exits 1 saying so. `skills/backdraft-artifact/SKILL.md` is rewritten
67
+ around the command, keeping the prose procedure only as the fallback for an
68
+ agent that has no backdraft install.
69
+
70
+ **Size.** Three days.
71
+
72
+ ### 2. `spec/registry.md`: the export format is normative, or it is not a format
73
+
74
+ **Intent.** `backdraft export` writes `"$format": "backdraft/registry-v1"`, a
75
+ version string that promises a specification and does not have one. `spec/`
76
+ holds `artifact.md`, `tokens.md` and `chunking.md`, and the repo's own rule is
77
+ that another implementation reads those and nothing else — the export is the
78
+ only declared format outside that set. It is also the only complete, portable
79
+ representation of a registry, so it is what a second implementation, a
80
+ migration, or an audit would read. The gap is not theoretical: the 2026-08-05
81
+ work added a conditional `meta` key to every exported document, a format change
82
+ to a format with nothing to change.
83
+
84
+ **Shape.** A new `spec/registry.md` in the voice of `spec/artifact.md`:
85
+ normative prose, MUST/OPTIONAL where it means them, and written to be readable
86
+ without the code. Cover the top-level shape, the document entry including the
87
+ `meta` key and its `url`/`fetched_at` (OPTIONAL, present only for a fetched
88
+ source — the same conditional the artifact spec already states, for the same
89
+ compatibility reason), every extraction generation with `is_current`, pages
90
+ and cells, and anchors with their receipts. State what identity is and what it
91
+ is not, matching the concept table in `SPEC.md`. Say what a conforming reader
92
+ must do with a key it does not recognize. Pin it the way the artifact format is
93
+ pinned: a golden-file test over the demo registry's export, so the next
94
+ conditional key is a test failure rather than an undocumented change.
95
+
96
+ **Acceptance.** `spec/registry.md` describes every key `Registry.export`
97
+ actually emits — check that mechanically, with a test that walks the exported
98
+ JSON and fails on a key the spec does not name. `SPEC.md`'s file map and
99
+ `README.md`'s spec list gain it. Exporting the demo registry and reading only
100
+ the spec is enough to say what each field means.
101
+
102
+ **Size.** Two days.
103
+
104
+ ### 3. `search` says when it stopped short
105
+
106
+ **Intent.** `backdraft search "the"` prints `20 results for "the"` whether there
107
+ are twenty or two hundred: `--limit` defaults to 20, and the count line reports
108
+ the size of the *page*, not the size of the answer. Search is the discovery
109
+ surface an agent leans on hardest — it is how a claim finds its evidence — and
110
+ an agent that reads "20 results" reasonably concludes it has seen all of them,
111
+ picks the best of twenty, and never learns that the sentence it actually needed
112
+ was twenty-first. `--limit 2` says `2 results` with no hint anything was cut.
113
+ This is the silent-cap failure the repo rejects everywhere else: `read`
114
+ paginates and closes with `[Showing 0-464 of 3115 chars. Continue with: ...]`,
115
+ which is exactly the shape missing here.
116
+
117
+ **Shape.** `registry.store.Registry.search` and `gate/searcher.py`. The registry
118
+ needs to know the total before it truncates — count the FTS matches and carry
119
+ the number alongside the hits, on `SearchResults`, which is already a list
120
+ subclass carrying `phrase_fallback` for precisely this kind of out-of-band fact
121
+ (SPEC Addendum A pins that shape, so extending it is a spec change and Addendum
122
+ A must say so). Then `render_search` reports honestly: an uncapped run keeps the
123
+ line it prints today, byte for byte, because most runs are uncapped and their
124
+ output is a contract; a capped run says how many matched and how to see the
125
+ rest, in `read`'s continuation-hint voice and naming the real flag. Do not add
126
+ an offset to `search` for this — `--limit` already exists, the hint can name it,
127
+ and paging a relevance-ordered list is a different design question. Keep the
128
+ count cheap: a `COUNT(*)` over the same FTS query, not a fetch-everything-and-
129
+ slice, since a registry of a hundred documents is the case that matters.
130
+
131
+ **Acceptance.** In a registry with more matches than the limit, `backdraft
132
+ search <q> --limit 2` names the true total and tells the reader how to widen it;
133
+ with fewer matches than the limit, the output is byte-identical to what it
134
+ prints today — pin that as a test, the way the artifact's byte-identity is
135
+ pinned. `--in <slug>` scoping counts within the scope, not across the registry.
136
+ `site/llms.txt` and `skills/backdraft/SKILL.md` tell the agent to widen rather
137
+ than settle when the line says it was capped.
138
+
139
+ **Size.** One day.
140
+
141
+ ### 4. The bind report says why a verifier skipped
142
+
143
+ **Intent.** A bind report prints `overlap: pass 11, skip 4` and stops. Four
144
+ citations were not checked and the report does not say why, so a reader cannot
145
+ tell a benign skip from a hole in the verification — and the reason exists
146
+ already: the record's verdicts carry `"detail": "wording overlap does not apply
147
+ to a single cell"` for every one of those four. This is the same gap the
148
+ 2026-08-13 line-item work closed one level up, where the token was printed and
149
+ the claim it sat on was not: the record says it, nested, and the line a person
150
+ reads does not. `DESIGN.md` holds that verifiers are evidence and never gate,
151
+ which makes the reason *more* important, not less — a skip nobody can interpret
152
+ is the one way a non-gating verifier can still mislead.
153
+
154
+ **Shape.** `bind/cli.py`'s `_print_report`, output only; no verifier, record or
155
+ artifact change. Skips of one method cluster on one reason, so group by reason
156
+ the way `ingest`'s snapshot note groups (`cli.py`'s `unsnapshot` dict) rather
157
+ than listing one line per citation the way its unread-source report does — the
158
+ distinction is written down in the 2026-08-13 decision row and this is the
159
+ grouping case. Keep it under the method's existing summary line and keep it one
160
+ line per distinct reason. `fail` is not involved: a skip is not a failure, the
161
+ exit code does not move, and a run whose verifiers all passed prints exactly
162
+ what it prints today.
163
+
164
+ **Acceptance.** Binding the demo (`bind memo.md --session s-bridgeview --check
165
+ value-trace,overlap`) prints the four skips' reason under the `overlap` line;
166
+ exit code is still 2 for the unresolved citation and would be 0 without it. A
167
+ run with no skips is byte-identical to today's, pinned as a test. A method
168
+ skipping for two different reasons prints two lines. `demo/walkthrough.md`'s
169
+ bind blocks, `README.md`'s, and `skills/backdraft/SKILL.md`'s show the real new
170
+ output, and the skill says a skip with a reason is not a hole to fix.
171
+
172
+ **Size.** One day.
173
+
174
+ ### 5. `session show` says what the ledger holds
175
+
176
+ **Intent.** The ledger is the mechanism the design rests on — the set of citable
177
+ tokens is exactly the set the gate emitted — and nothing reads it back.
178
+ `backdraft session show` prints `session default (from default)` and stops, so
179
+ an agent cannot answer "have I read enough to write this yet?" except by binding
180
+ and reading the `not_shown` count, which means finding out after the draft
181
+ exists. Worse, the default session is documented as stable across invocations
182
+ *so reads accumulate*, which means an agent that never exported
183
+ `BACKDRAFT_SESSION` has its `not_shown` judged against everything ever read in
184
+ that registry by any previous run on any document — a real weakening of the
185
+ system's strongest check, currently invisible at every surface. `skills/
186
+ backdraft/SKILL.md` tells the agent to start a named session and calls it
187
+ optional; nothing shows it what optional costs.
188
+
189
+ **Shape.** `gate/cli.py`'s `session show` and `gate/reader.py` (the ledger is
190
+ the gate's, and `session start` already lives there). Report what the session
191
+ holds: how many anchors, across how many documents, and per document a slug and
192
+ a count — read off the `shown` rows the registry already keeps, which may need
193
+ one accessor on `Registry` beside `was_shown` (SPEC Addendum A, so state it
194
+ there). When the session is the default one, say plainly that it accumulates
195
+ across runs and that a named session is what makes `not_shown` mean "this
196
+ draft's author saw it" — the same voice the `--config` and poppler notes use, a
197
+ note at exit 0 and never a failure. An empty session says so and names
198
+ `backdraft read` rather than printing a bare zero.
199
+
200
+ **Acceptance.** After reading two pages of one document and searching another,
201
+ `backdraft session show` names both documents with their counts and a total; on
202
+ a fresh registry it says the session is empty and what to run. Running under
203
+ `BACKDRAFT_SESSION=s-x` shows `s-x` and no accumulation note; running with
204
+ nothing set shows the default and the note. `README.md`, `site/docs.html`,
205
+ `site/llms.txt` and `skills/backdraft/SKILL.md` point at it as the way to check
206
+ coverage before binding, and the skill's "optional" for `session start` becomes
207
+ a stated tradeoff.
208
+
209
+ **Size.** Two days.
210
+
211
+ ### 6. An unreadable source says what to do, not what errno it was
212
+
213
+ **Intent.** `ingest` now finishes its list and names each failure (2026-08-13),
214
+ which turned the reason string into something a calling agent actually reads —
215
+ and the reason is Python. A directory says `cannot read adir: [Errno 21] Is a
216
+ directory: 'adir'`; a locked file says `[Errno 13] Permission denied`; a missing
217
+ one says `[Errno 2] No such file or directory`. Each names the source three
218
+ times, leaks an errno an agent must not have to know, and — the real cost —
219
+ says nothing about the fix, though the fix differs completely between the three
220
+ and is obvious in each case. A directory means "name the files inside it, or
221
+ glob"; the repo's own rule is that an error says what went wrong *and* what to
222
+ do next, and this is the boundary where an unattended agent meets it most.
223
+
224
+ **Shape.** Wherever the read failure is raised on the ingest path — `cli.py`'s
225
+ `_staged`, `registry/store.py`'s ingest read, and `fetch.py` for the URL half —
226
+ turn the `OSError` into a `BackdraftError` whose message is written for the
227
+ caller: what the source is, why it could not be read, what to do. Map the causes
228
+ worth distinguishing (missing, a directory, unreadable, empty, and the fetch
229
+ side's own) and let anything unmapped keep the underlying text rather than
230
+ guessing — an unknown cause reported plainly beats a wrong suggestion. Say the
231
+ source once: the `!` line already leads with it, so the reason should not repeat
232
+ it. This is *not* the queued "Ingest says what it did and what it got" item, which is about what a successful ingest says
233
+ (no-op, new generation, character count); this is the failure half, and the two
234
+ should not be merged.
235
+
236
+ **Acceptance.** `backdraft ingest <a-directory>` names the directory once and
237
+ says to pass the files inside it or a glob; a missing file, an unreadable file
238
+ and an empty file each get their own reason and their own next step; none of the
239
+ four prints `[Errno`. A URL that 404s keeps saying so. The count line and the
240
+ closing re-run line are unchanged, and the clean-run stdout byte-identity test
241
+ from 2026-08-13 still passes. `site/llms.txt` says the failure reasons are
242
+ actionable so an agent surfaces them to the user rather than paraphrasing.
243
+
244
+ **Size.** One day.
245
+
246
+ ### 7. A registry can forget a source
247
+
248
+ **Intent.** Ingest is one-way. An agent working unattended over a folder — which
249
+ is now the normal case, since `ingest` finishes its list — will ingest something
250
+ it should not have: a scratch copy, a duplicate under two names, a file the user
251
+ did not mean to include. Nothing removes it. It stays in `backdraft read`'s list
252
+ forever, its anchors stay in `search` competing with the real source, and an
253
+ agent picking evidence can cite the wrong copy without anything looking wrong.
254
+ The only recovery is deleting `.backdraft/` and starting over, which throws away
255
+ every other document and the whole ledger.
256
+
257
+ **Shape.** Withdraw, do not delete — a new `backdraft forget <slug>` in `cli.py`
258
+ beside `ingest`, and the storage half in `registry/store.py`. A withdrawn
259
+ document disappears from `documents()`, from the gate's list and table of
260
+ contents, and from `search`; its anchors keep resolving, because a token already
261
+ written into somebody's draft or artifact must not silently become `unresolved`
262
+ — that is the failure mode this whole system exists to prevent, and the
263
+ 2026-08-05 "provenance, never identity" row governs. So `Registry.resolve` still
264
+ finds it and `show` and `bind` report it, with the closed status set gaining
265
+ nothing: reuse `unresolved` and let the *reason* say withdrawn, since adding a
266
+ status is an artifact-format change and this does not need one. Storage is a
267
+ flag on `documents`, not a DELETE, for the same reason. Require confirmation or
268
+ an explicit flag, since this is the one command that takes something away.
269
+ Interacts with the queued `spec/registry.md` item: whichever lands second
270
+ documents the flag in the export.
271
+
272
+ **Acceptance.** Ingest two documents, `forget` one: `read`, `ls` and `search`
273
+ show only the other; a token minted from the forgotten one still resolves
274
+ through `backdraft show`, saying it was withdrawn and when; `bind` on a document
275
+ citing it reports it and does not crash. `forget` on an unknown slug exits 1
276
+ naming the known ones. Re-ingesting the same file after forgetting it brings it
277
+ back as a new generation of the same document rather than a second slug — pin
278
+ that, it is the case that decides whether the flag was the right storage.
279
+ `README.md`, `site/docs.html`, `site/llms.txt` and `SPEC.md`'s command list gain
280
+ it, and it earns a DESIGN.md row: withdraw-not-delete, with the tradeoff that a
281
+ registry never actually shrinks.
282
+
283
+ **Size.** Three days.
284
+
285
+ ## Parked
286
+
287
+ Deliberately not queued, each with the reason, so picking one up starts from
288
+ the objection rather than rediscovering it.
289
+
290
+ - **Exhibits view** — the evidence-first inversion. Risk: it grows the
291
+ artifact and pollutes a design whose strength is restraint. Needs a
292
+ deliberate design pass and a real audience before any code; not a
293
+ weekend-feature shape.
294
+ - **Excel region maps and range reads** — the research notes exist, but no
295
+ confidence the deterministic detection cascade lands well on the first
296
+ try. Revisit when large-sheet navigation hurts in a real corpus, with
297
+ test workbooks in hand.
298
+ - **`bd:calc` derivations** — reserved in the grammar and parsed as
299
+ malformed-on-purpose, which is enough for now. Full support complicates
300
+ binding and verification for one field-trial case.
301
+ - **Merged-cell and frozen-pane rendering** — ingest captures `merged` and
302
+ `frozen` in sheet meta and keeps them (cheap, and capture is the part you
303
+ cannot backfill). Frozen-pane rendering is cut; merged-cell rendering
304
+ waits for a real sheet that renders wrong without it.
305
+
306
+ ## Later
307
+
308
+ - **Living documents** — re-ingest, re-bind, and a drift-first report: "these
309
+ claims cite figures that changed; cited-then vs. now." The primitives
310
+ (generations, `drifted`, the word-diff) all exist; the missing piece is a
311
+ diff-shaped report and a demo.
312
+ - **Substrates beyond the CLI** — SDK middleware for pipelines (the exit-code
313
+ and `--to json` contracts are already shaped for it), and a client-side
314
+ drag-drop viewer page (no upload — the file never leaves the reader's
315
+ machine).
316
+ - **Email (.eml)** — arrives in diligence folders alongside everything else;
317
+ identity questions (headers vs. body, attachments as child sources) deserve
318
+ a decision row before code. The web-page half of this item shipped
319
+ 2026-08-05; `document_meta` is where an email's provenance would land.
320
+ - **Anthropic-API provider for the VLM extractor** — Cowork's sandbox
321
+ egress allowlist includes api.anthropic.com but not the OpenAI-compatible
322
+ providers, so an Anthropic-native option would let sandboxed sessions
323
+ ingest at full fidelity. Needs a client abstraction the vlm module
324
+ currently doesn't have; the documented workaround (ingest locally, the
325
+ registry travels with the folder) covers it meanwhile.
326
+ - **Distribution** — published on PyPI; the repo ships
327
+ `.claude-plugin/plugin.json` and `marketplace.json`, so it installs as a
328
+ Claude Code plugin and self-hosts its marketplace, and the AGENTS.md
329
+ snippet is published in the README and site docs. The community-directory
330
+ submission went in 2026-07-29 and is awaiting review — approval is what
331
+ surfaces the skills in Cowork's directory.
332
+
333
+ ## Someday
334
+
335
+ - **Hosted team features** — upload, access control, shared registries.
336
+ Deliberately parked: the product's trust story is the self-contained file,
337
+ and a hosted viewer inverts it. Revisit only as a distinct teams product.
338
+ - **Entail at scale** — the model-judge verifier is wired (`[entail]` extra)
339
+ but has never been field-calibrated; needs a corpus and a rubric before it
340
+ is recommended anywhere.
@@ -241,7 +241,7 @@ Registered in a plain dict; `ingest --extractor auto` picks the first `can_handl
241
241
  ## Gate (gate/)
242
242
 
243
243
  CLI-facing behavior contract:
244
- - `read` with no args → document list (slug, filename, media, pages). `read <slug>` → TOC (page/sheet, name, summary-or-first-120-chars). `read <slug> p3` / `p3-5` / `rent-roll` → token-marked content.
244
+ - `read` with no args → document list (slug, name, media, pages), where the name is the filename or — for a source fetched from the web — the origin URL standing in its place (`gate.source_name`, shared with the CLI's `ingest` and `ls`). `read <slug>` → TOC (page/sheet, name, summary-or-first-120-chars). `read <slug> p3` / `p3-5` / `rent-roll` → token-marked content.
245
245
  - PDF pages render as chunks: `[bd:slug:p3.c1:a7f3]` on its own line above each chunk's text. Sheets render the markdown table as-is with a header line carrying the page token; cell tokens are NOT inlined per-cell (the `[B10]` refs are already in-band; bind composes `bd:slug:sheet!B10:hash` from the registry).
246
246
  - `search "<query>"` → FTS5 over anchors; each result: token, slug, page, snippet. Results are minted (ledger-recorded) — a searched snippet is citable without a page read.
247
247
  - `show <token>...` → the inverse of minting: per token, its bind status, its slug and locator, and the verbatim snippet, in argument order. `resolved` prints the snippet; `drifted` prints both the cited snippet and what stands at the locator now (and mints that anchor too, since it is the one worth citing); `unresolved` says whether the slug or the locator is what named nothing; `malformed` carries the kernel's reason and the grammar. Statuses and resolution are bind's, reached through `kernel.claims.parse_citation` and `Registry.resolve` rather than a second reading of what a token means; `not_shown` cannot occur, because showing mints. Exit 1 if any token was `unresolved` or `malformed`, with the block still on stdout.
@@ -342,6 +342,10 @@ class Registry:
342
342
  def ingest(self, path: Path, *, extractor: str | None = None,
343
343
  slug: str | None = None, config: dict | None = None,
344
344
  url: str | None = None, fetched_at: str | None = None) -> Document: ...
345
+ # Concretely an `Ingested` (a `Document` subclass) carrying `outcome: str` — one of
346
+ # CREATED, GENERATION, UNCHANGED, the three things ingest can do. Consumers may
347
+ # ignore it; the CLI prints it, since only the registry can tell a no-op from a
348
+ # new generation and re-deriving it outside means re-implementing `_is_noop`.
345
349
  # runs extractor → new extraction generation, pages, eager anchors, FTS rows.
346
350
  # Token carry-over per re-ingest semantics; hash-collision extension steps TOKEN_HASH_LENGTHS.
347
351
  # Sheet names sanitized to the sheetref charset here, at ingest.
@@ -390,8 +394,8 @@ class Registry:
390
394
  def export_json(self) -> dict: ...
391
395
  ```
392
396
 
393
- `Resolution` and `SearchHit` are small frozen dataclasses in `registry/store.py`; `SearchResults` is the `list[SearchHit]` subclass described above.
397
+ `Resolution` and `SearchHit` are small frozen dataclasses in `registry/store.py`; `SearchResults` is the `list[SearchHit]` subclass described above, and `Ingested` the `Document` subclass. Both subclasses exist so a pinned return type can carry an out-of-band fact without every consumer and every fake changing shape.
394
398
 
395
399
  ## Addendum B — CLI assembly
396
400
 
397
- Top-level `cli.py` (W1) owns: typer app, registry discovery (nearest `.backdraft/` walking up from cwd; `BACKDRAFT_HOME` override), session resolution (`--session` flag > `BACKDRAFT_SESSION` env > default session), and the `init` / `ingest` / `ls` / `export` / `snapshot-pages` commands. `ingest` runs its whole source list: a `BackdraftError` from one source is caught, recorded against that source, and the loop continues, so a folder ingest is never half-attempted. Every failure is printed at the end as one `!` line naming the source and the reason, under a count of what landed, and the command exits 1 — the guard in `cli_context` stays the only place a `BackdraftError` becomes an exit code, since nothing caught per source is re-raised. After each ingest, `cli.py` captures page snapshots for any PDF whose extraction carries none — the text-layer path, since `vlm` stores its own — via `extract/snapshots.py` (poppler through pdf2image, `snapshot-pages`' internals). It sits outside the extractor so `pdf-text` stays deterministic regardless of whether poppler is installed, and it is best-effort: a `SnapshotError` leaves ingest at exit 0 with a one-line note naming `snapshot-pages` as the backfill. `cli.py` also owns the network for the same reason: an `ingest` argument with a scheme is fetched through `fetch.py` (stdlib `urllib`, http and https only, redirects followed, 32 MiB cap) and staged in a temporary file named for the content type the server declared — which is what selects the extractor — so extractors stay pure functions of bytes and the registry opens no sockets. It mounts sub-apps `gate/cli.py` (W2: `read`, `search`, `session`), `bind/cli.py` (W3: `bind`), `render/cli.py` (W4: `render`) — each exposes `app = typer.Typer()`; the top level mounts each inside a try/except ImportError so partial merges still run. The `backdraft` console script is declared by W1.
401
+ Top-level `cli.py` (W1) owns: typer app, registry discovery (nearest `.backdraft/` walking up from cwd; `BACKDRAFT_HOME` override), session resolution (`--session` flag > `BACKDRAFT_SESSION` env > default session), and the `init` / `ingest` / `ls` / `export` / `snapshot-pages` commands. `ingest` runs its whole source list: a `BackdraftError` from one source is caught, recorded against that source, and the loop continues, so a folder ingest is never half-attempted. Every failure is printed at the end as one `!` line naming the source and the reason, under a count of what landed, and the command exits 1 — the guard in `cli_context` stays the only place a `BackdraftError` becomes an exit code, since nothing caught per source is re-raised. Each source that landed prints its extracted character count and, where it is not a fresh document, which of `Ingested.outcome`'s other two values it was: `unchanged` for a no-op, `new generation` for bytes that moved, the latter followed by one grouped note naming the documents and the `drifted` consequence. A document whose extraction came in under `cli.THIN_SOURCE_CHARS` adds a note grouped by cause (media-type-specific: a PDF's missing text layer, a page behind a login, an image-only deck; anything unmapped says plainly that it has nothing specific to offer), at exit 0 — a thin snapshot is a real snapshot, and the note is display only. After each ingest, `cli.py` captures page snapshots for any PDF whose extraction carries none — the text-layer path, since `vlm` stores its own — via `extract/snapshots.py` (poppler through pdf2image, `snapshot-pages`' internals). It sits outside the extractor so `pdf-text` stays deterministic regardless of whether poppler is installed, and it is best-effort: a `SnapshotError` leaves ingest at exit 0 with a one-line note naming `snapshot-pages` as the backfill. `cli.py` also owns the network for the same reason: an `ingest` argument with a scheme is fetched through `fetch.py` (stdlib `urllib`, http and https only, redirects followed, 32 MiB cap) and staged in a temporary file named for the content type the server declared — which is what selects the extractor — so extractors stay pure functions of bytes and the registry opens no sockets. It mounts sub-apps `gate/cli.py` (W2: `read`, `search`, `session`), `bind/cli.py` (W3: `bind`), `render/cli.py` (W4: `render`) — each exposes `app = typer.Typer()`; the top level mounts each inside a try/except ImportError so partial merges still run. The `backdraft` console script is declared by W1.