backdraft 0.6.0__tar.gz → 0.7.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.6.0 → backdraft-0.7.0}/.claude-plugin/plugin.json +1 -1
  2. {backdraft-0.6.0 → backdraft-0.7.0}/DESIGN.md +3 -0
  3. {backdraft-0.6.0 → backdraft-0.7.0}/PKG-INFO +30 -5
  4. {backdraft-0.6.0 → backdraft-0.7.0}/README.md +28 -3
  5. backdraft-0.7.0/ROADMAP.md +316 -0
  6. {backdraft-0.6.0 → backdraft-0.7.0}/SPEC.md +7 -2
  7. {backdraft-0.6.0 → backdraft-0.7.0}/demo/walkthrough.md +51 -4
  8. {backdraft-0.6.0 → backdraft-0.7.0}/pyproject.toml +1 -1
  9. {backdraft-0.6.0 → backdraft-0.7.0}/site/docs.html +3 -1
  10. {backdraft-0.6.0 → backdraft-0.7.0}/site/llms.txt +30 -9
  11. {backdraft-0.6.0 → backdraft-0.7.0}/skills/backdraft/SKILL.md +41 -12
  12. {backdraft-0.6.0 → backdraft-0.7.0}/skills/backdraft-artifact/SKILL.md +25 -3
  13. {backdraft-0.6.0 → backdraft-0.7.0}/skills/backdraft-backfill/SKILL.md +13 -0
  14. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/bind/binder.py +6 -15
  15. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/bind/cli.py +61 -3
  16. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/cli.py +87 -28
  17. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/cli_context.py +8 -1
  18. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/base.py +53 -2
  19. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/image.py +9 -2
  20. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/pdf_text.py +6 -0
  21. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/snapshots.py +37 -2
  22. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/vlm.py +15 -2
  23. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/vlm_settings.py +17 -0
  24. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/gate/__init__.py +5 -5
  25. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/gate/cli.py +35 -2
  26. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/gate/reader.py +155 -2
  27. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/registry/__init__.py +2 -0
  28. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/registry/store.py +37 -1
  29. {backdraft-0.6.0 → backdraft-0.7.0}/tests/conftest_registry.py +11 -1
  30. {backdraft-0.6.0 → backdraft-0.7.0}/tests/gate/test_gate_cli.py +42 -0
  31. backdraft-0.7.0/tests/gate/test_reader_show.py +157 -0
  32. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_bind_cli.py +100 -0
  33. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_cli.py +117 -3
  34. backdraft-0.7.0/tests/test_docs.py +44 -0
  35. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_extract.py +90 -1
  36. backdraft-0.7.0/tests/test_gate_integration.py +211 -0
  37. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_reingest.py +56 -4
  38. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_snapshots.py +14 -0
  39. {backdraft-0.6.0 → backdraft-0.7.0}/uv.lock +1 -1
  40. backdraft-0.6.0/ROADMAP.md +0 -258
  41. backdraft-0.6.0/tests/test_gate_integration.py +0 -91
  42. {backdraft-0.6.0 → backdraft-0.7.0}/.claude-plugin/marketplace.json +0 -0
  43. {backdraft-0.6.0 → backdraft-0.7.0}/.github/workflows/ci.yml +0 -0
  44. {backdraft-0.6.0 → backdraft-0.7.0}/.github/workflows/release.yml +0 -0
  45. {backdraft-0.6.0 → backdraft-0.7.0}/.github/workflows/site.yml +0 -0
  46. {backdraft-0.6.0 → backdraft-0.7.0}/.gitignore +0 -0
  47. {backdraft-0.6.0 → backdraft-0.7.0}/.python-version +0 -0
  48. {backdraft-0.6.0 → backdraft-0.7.0}/LICENSE +0 -0
  49. {backdraft-0.6.0 → backdraft-0.7.0}/assets/backdraft-mark.svg +0 -0
  50. {backdraft-0.6.0 → backdraft-0.7.0}/demo/generate_sources.py +0 -0
  51. {backdraft-0.6.0 → backdraft-0.7.0}/demo/memo.backdraft.html +0 -0
  52. {backdraft-0.6.0 → backdraft-0.7.0}/demo/memo.md +0 -0
  53. {backdraft-0.6.0 → backdraft-0.7.0}/demo/sources/t12-summary.pdf +0 -0
  54. {backdraft-0.6.0 → backdraft-0.7.0}/demo/sources/underwriting-model.xlsx +0 -0
  55. {backdraft-0.6.0 → backdraft-0.7.0}/site/demo.html +0 -0
  56. {backdraft-0.6.0 → backdraft-0.7.0}/site/index.html +0 -0
  57. {backdraft-0.6.0 → backdraft-0.7.0}/site/privacy.html +0 -0
  58. {backdraft-0.6.0 → backdraft-0.7.0}/spec/artifact.md +0 -0
  59. {backdraft-0.6.0 → backdraft-0.7.0}/spec/chunking.md +0 -0
  60. {backdraft-0.6.0 → backdraft-0.7.0}/spec/tokens.md +0 -0
  61. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/__init__.py +0 -0
  62. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/__main__.py +0 -0
  63. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/bind/__init__.py +0 -0
  64. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/bind/evidence.py +0 -0
  65. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/bind/verify/__init__.py +0 -0
  66. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/bind/verify/base.py +0 -0
  67. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/bind/verify/entail.py +0 -0
  68. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/bind/verify/overlap.py +0 -0
  69. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/bind/verify/recompute.py +0 -0
  70. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/bind/verify/value_trace.py +0 -0
  71. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/credentials.py +0 -0
  72. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/__init__.py +0 -0
  73. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/csv.py +0 -0
  74. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/docx.py +0 -0
  75. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/html.py +0 -0
  76. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/pptx.py +0 -0
  77. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/sheet.py +0 -0
  78. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/text.py +0 -0
  79. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/xls.py +0 -0
  80. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/extract/xlsx.py +0 -0
  81. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/fetch.py +0 -0
  82. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/gate/searcher.py +0 -0
  83. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/kernel/__init__.py +0 -0
  84. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/kernel/artifact.py +0 -0
  85. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/kernel/chunking.py +0 -0
  86. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/kernel/claims.py +0 -0
  87. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/kernel/errors.py +0 -0
  88. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/kernel/hashing.py +0 -0
  89. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/kernel/model.py +0 -0
  90. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/kernel/tokens.py +0 -0
  91. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/registry/ledger.py +0 -0
  92. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/registry/schema.sql +0 -0
  93. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/__init__.py +0 -0
  94. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/_text.py +0 -0
  95. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/cli.py +0 -0
  96. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/footnotes.py +0 -0
  97. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/html/__init__.py +0 -0
  98. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/html/assets.py +0 -0
  99. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/html/components.py +0 -0
  100. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/html/fmt.py +0 -0
  101. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/html/fmt_vectors.py +0 -0
  102. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/html/page.py +0 -0
  103. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/html/text.py +0 -0
  104. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/markdown.py +0 -0
  105. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/placement.py +0 -0
  106. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/sidecar.py +0 -0
  107. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/theme.py +0 -0
  108. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/themes/default.toml +0 -0
  109. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/themes/press.toml +0 -0
  110. {backdraft-0.6.0 → backdraft-0.7.0}/src/backdraft/render/themes/slate.toml +0 -0
  111. {backdraft-0.6.0 → backdraft-0.7.0}/tests/conftest.py +0 -0
  112. {backdraft-0.6.0 → backdraft-0.7.0}/tests/conftest_render.py +0 -0
  113. {backdraft-0.6.0 → backdraft-0.7.0}/tests/css_util.py +0 -0
  114. {backdraft-0.6.0 → backdraft-0.7.0}/tests/fakes.py +0 -0
  115. {backdraft-0.6.0 → backdraft-0.7.0}/tests/gate/conftest.py +0 -0
  116. {backdraft-0.6.0 → backdraft-0.7.0}/tests/gate/fake_registry.py +0 -0
  117. {backdraft-0.6.0 → backdraft-0.7.0}/tests/gate/test_gate_ledger.py +0 -0
  118. {backdraft-0.6.0 → backdraft-0.7.0}/tests/gate/test_gate_reader.py +0 -0
  119. {backdraft-0.6.0 → backdraft-0.7.0}/tests/gate/test_gate_searcher.py +0 -0
  120. {backdraft-0.6.0 → backdraft-0.7.0}/tests/gate/test_reader_cells.py +0 -0
  121. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/bind/backfill.bound.md +0 -0
  122. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/bind/frontwalk.bound.md +0 -0
  123. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/chunking/long_tail.json +0 -0
  124. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/chunking/long_tail.txt +0 -0
  125. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/chunking/many_small.json +0 -0
  126. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/chunking/many_small.txt +0 -0
  127. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/chunking/mixed.json +0 -0
  128. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/chunking/mixed.txt +0 -0
  129. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/chunking/one_giant.json +0 -0
  130. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/chunking/one_giant.txt +0 -0
  131. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/chunking/short_page.json +0 -0
  132. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/chunking/short_page.txt +0 -0
  133. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/chunking/unsplittable.json +0 -0
  134. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/chunking/unsplittable.txt +0 -0
  135. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/render/backfill.footnotes.md +0 -0
  136. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/render/backfill.sidecar.json +0 -0
  137. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/render/demo.footnotes.md +0 -0
  138. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden/render/demo.sidecar.json +0 -0
  139. {backdraft-0.6.0 → backdraft-0.7.0}/tests/golden_util.py +0 -0
  140. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_bind_binder.py +0 -0
  141. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_bind_evidence.py +0 -0
  142. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_bind_verify.py +0 -0
  143. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_card_sizing.py +0 -0
  144. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_chunking.py +0 -0
  145. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_claims.py +0 -0
  146. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_credentials.py +0 -0
  147. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_extract_html.py +0 -0
  148. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_fetch.py +0 -0
  149. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_fmt_parity.py +0 -0
  150. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_footnotes.py +0 -0
  151. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_hashing.py +0 -0
  152. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_html.py +0 -0
  153. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_ingest_url.py +0 -0
  154. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_invariants.py +0 -0
  155. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_model.py +0 -0
  156. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_placement.py +0 -0
  157. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_records_location.py +0 -0
  158. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_registry.py +0 -0
  159. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_render_cli.py +0 -0
  160. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_render_markdown.py +0 -0
  161. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_sidecar.py +0 -0
  162. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_snapshot_settings.py +0 -0
  163. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_theme.py +0 -0
  164. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_tokens.py +0 -0
  165. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_value_trace.py +0 -0
  166. {backdraft-0.6.0 → backdraft-0.7.0}/tests/test_version.py +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "backdraft",
3
3
  "displayName": "Backdraft",
4
- "version": "0.6.0",
4
+ "version": "0.7.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",
@@ -140,6 +140,9 @@ Self-contained HTML (plus the machine-readable sidecar): **quotes + receipts + e
140
140
  | 2026-08-04 | **Failure speaks at the claim, not in the masthead (amends the 2026-07-28 reader doctrine).** The artifact no longer prints a failure summary under the title. "Success is silent; only failure speaks" stands, and so does the principle under it — failures are data, never warn-and-drop — but *where* it speaks moves: the wavy alarm-coloured mark on the affected claim, and that claim's note with the reason, both of which a reader meets in context. The line that went was a bare count, and a count is the one thing about a failed citation a reader cannot act on: whether `1 of 16 citations could not be traced` matters depends entirely on which claim it was, which the headline does not say and the mark and the note do. It was also aimed at the wrong reader — the writer verifying the work learns about failures from `bind`, which exits 2 and lists them, while the recipient got a number out of context before the first sentence. Nothing loses its last surface: an unmatched claim with no citations ranks `unresolved` and is marked in the body like any other failure, and a claim that is not in the document at all was already the Notes' job under artifact rule 4. This is a spec change — `spec/artifact.md` rule 6 is rewritten, and the summary is now explicitly *not* required of a conforming producer rather than merely unmentioned, so another implementation reads the same intent. |
141
141
  | 2026-08-05 | **A web page's identity is the sha256 of the snapshot fetched at ingest time; the URL is provenance, not identity.** `ingest` accepts an http(s) URL alongside files (graduating the "HTML pages" question from Later). The fetched bytes are hashed and snapshotted exactly as a file's are, so everything downstream — anchors, the ledger, `bind`, generations — is unchanged, and a page that changed since the last fetch is a *new generation of the same document*, which is the machinery drift already describes. Continuity across a re-fetch follows the URL rather than the path, because the path is a temporary staging file; `documents.sha256` is UNIQUE, so bytes still win over origin when the same content arrives twice under two URLs. The URL and `fetched_at` live in a new `document_meta` table — a JSON sibling of `page_meta`, chosen over columns on `documents` so an existing registry needs no migration and so the next provenance question (`.eml` headers) needs no second one; `fetched_at` moves on a re-fetch that changed nothing, since "when was this last confirmed to say this" is the question a reader of an old citation actually has. Fetching is stdlib `urllib` and lives in `fetch.py`, *outside* `extract/`: an extractor stays a pure function of a file's bytes, which is what keeps `deterministic` honest and lets a saved `.html` file and a fetched page extract identically. The CLI owns the network the way it already owns page-snapshot capture. The new `html` extractor is a parse, not a readability heuristic — no boilerplate stripping, no main-content guess, because a heuristic that changes its mind moves anchors, which is the failure mode this system exists to prevent; navigation and footers are part of the page. Its representation follows `docx`: tables as markdown pipe tables, lists as `- `/`1. ` lines kept in one block, `<pre>` verbatim. Decoding is BOM, then the document's own `<meta charset>`, then UTF-8 with replacement — the server's `Content-Type` charset is deliberately *not* consulted, so the bytes alone determine the text. Known limitations, accepted and documented rather than worked around: JavaScript-rendered pages yield whatever the server sends, pages behind a login are out of reach, and a non-UTF-8 page that declares its encoding only in the HTTP header will render as mojibake in the snapshot — visibly, rather than silently. |
142
142
  | 2026-08-06 | **A fetched source's origin travels into the artifact as a live link, dated.** The capture half of the row above stored `url` and `fetched_at` and stopped at the registry, so an artifact citing a web page named `q4-2025.html` — a staging filename that never existed on anyone's disk — and a reader holding the file had the frozen receipt with no way back to the page. `bind`'s evidence `documents` map now carries `url` and `fetched_at` through to the record, and both renderers show them: the receipt card, the script-free note, the source list and the rail all print the URL as a real `<a href>` with the fetch date beside it, and the markdown projection carries it as an autolink. This is an artifact-format change, so `spec/artifact.md` and the `$legend` name the two keys together, both OPTIONAL. The keys are emitted *only* where a document has a URL, which is what keeps an artifact built from files byte-identical to one built before this existed — the conditional is the compatibility guarantee, and it is a test rather than an intention. Linking does not weaken the self-containment rule: the CSP (`default-src 'none'`) forbids *fetching*, and an anchor fetches nothing until a reader chooses to follow it, so the file still renders identically offline. The href is allowlisted to `http`/`https` rather than merely screened for `javascript:`, since artifact rule 3 is a conformance requirement and an allowlist cannot be outflanked by a scheme nobody thought of; a URL under any other scheme is printed as plain text rather than dropped, because failures are data and provenance the reader must paste by hand still beats provenance withheld. In the source list the URL *replaces* the filename rather than joining it — showing both would give a reader two names for one thing and let the fictional one look authoritative. The date is the day, not the timestamp: "as of" is what makes a frozen quote from a live page defensible, and no reader needs the seconds. |
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
+ | 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
+ | 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. |
143
146
 
144
147
  ## Open
145
148
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: backdraft
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -56,7 +56,9 @@ cd demo
56
56
  cells for spreadsheet sheets. Formats: PDF, XLSX/XLSM, XLS, CSV/TSV, DOCX,
57
57
  PPTX, HTML, images (png, jpeg, tiff, through the vision model), plain text and
58
58
  Markdown. A source can also be an `http(s)` URL — the page is fetched once and
59
- snapshotted like any other source, and the URL travels with it.
59
+ snapshotted like any other source, and the URL travels with it. A source that
60
+ cannot be read does not end the run: the rest of the list is ingested anyway,
61
+ and the command exits 1 naming each source that failed and why.
60
62
 
61
63
  ```console
62
64
  $ backdraft init
@@ -117,6 +119,25 @@ $ backdraft cell underwriting-model "assumptions!B10"
117
119
  [bd:underwriting-model:assumptions!B10:964a] 24850000
118
120
  ```
119
121
 
122
+ And a token you were handed — out of an artifact, a draft, someone else's
123
+ message — runs backwards to what it actually says:
124
+
125
+ ```console
126
+ $ backdraft show bd:t12-summary:p1.c1:c2e8
127
+ [bd:t12-summary:p1.c1:c2e8] resolved t12-summary p1.c1
128
+ Bridgeview Commons — Trailing Twelve Month Summary
129
+
130
+ Property: Bridgeview Commons, 4400 Halsted Avenue, Columbus, OH 43214. 128 units across four three-story
131
+ garden buildings on 6.2 acres, built in 1998 and substantially renovated between 2019 and 2021. …
132
+
133
+ [Read the page: backdraft read t12-summary p1]
134
+ ```
135
+
136
+ `show` is the gate too, so what it shows is minted and citable. A token whose
137
+ source has changed since prints both snippets, the one that was cited and the
138
+ one standing there now; a token that names nothing exits 1 and says whether the
139
+ slug or the locator is the wrong half.
140
+
120
141
  **Cite** by writing the token as the href of a markdown link on the claim span.
121
142
  Multiple citations are `;`-separated in one href.
122
143
 
@@ -134,7 +155,7 @@ bound 15 claim(s), 16 citation(s) [frontwalk]
134
155
  unresolved: 1
135
156
  overlap: pass 11, skip 4
136
157
  value-trace: pass 15
137
- ! unresolved: bd:t12-summary:p4.c1:1a2b
158
+ ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @2900
138
159
  wrote .backdraft/records/memo.backdraft.json
139
160
 
140
161
  $ echo $?
@@ -269,7 +290,11 @@ explicit, backdraft-scoped consent: `BACKDRAFT_VLM_API_KEY` (env or
269
290
  `OPENAI_API_KEY`/`OPENROUTER_API_KEY` are deliberately never read; a generic
270
291
  key in the environment is not consent to send documents to its provider. The
271
292
  default provider is OpenRouter running Gemini 3.1 Flash Lite; direct OpenAI is
272
- base_url + model, set explicitly. Without a scoped key, `auto` falls back to
293
+ base_url + model, set explicitly (`--config base_url=...` and `--config
294
+ model=...`, one key per flag). Every `--config` key is declared by the extractor
295
+ that reads it and checked against the one `ingest` chose, so a misspelled key
296
+ fails and names the keys that apply rather than being silently ignored.
297
+ Without a scoped key, `auto` falls back to
273
298
  the keyless text layer and says so — still storing each page's image, which it
274
299
  renders locally. PDF page rendering needs poppler, installed separately; without
275
300
  it ingest succeeds without the images and names `snapshot-pages` as the backfill.
@@ -31,7 +31,9 @@ cd demo
31
31
  cells for spreadsheet sheets. Formats: PDF, XLSX/XLSM, XLS, CSV/TSV, DOCX,
32
32
  PPTX, HTML, images (png, jpeg, tiff, through the vision model), plain text and
33
33
  Markdown. A source can also be an `http(s)` URL — the page is fetched once and
34
- snapshotted like any other source, and the URL travels with it.
34
+ snapshotted like any other source, and the URL travels with it. A source that
35
+ cannot be read does not end the run: the rest of the list is ingested anyway,
36
+ and the command exits 1 naming each source that failed and why.
35
37
 
36
38
  ```console
37
39
  $ backdraft init
@@ -92,6 +94,25 @@ $ backdraft cell underwriting-model "assumptions!B10"
92
94
  [bd:underwriting-model:assumptions!B10:964a] 24850000
93
95
  ```
94
96
 
97
+ And a token you were handed — out of an artifact, a draft, someone else's
98
+ message — runs backwards to what it actually says:
99
+
100
+ ```console
101
+ $ backdraft show bd:t12-summary:p1.c1:c2e8
102
+ [bd:t12-summary:p1.c1:c2e8] resolved t12-summary p1.c1
103
+ Bridgeview Commons — Trailing Twelve Month Summary
104
+
105
+ Property: Bridgeview Commons, 4400 Halsted Avenue, Columbus, OH 43214. 128 units across four three-story
106
+ garden buildings on 6.2 acres, built in 1998 and substantially renovated between 2019 and 2021. …
107
+
108
+ [Read the page: backdraft read t12-summary p1]
109
+ ```
110
+
111
+ `show` is the gate too, so what it shows is minted and citable. A token whose
112
+ source has changed since prints both snippets, the one that was cited and the
113
+ one standing there now; a token that names nothing exits 1 and says whether the
114
+ slug or the locator is the wrong half.
115
+
95
116
  **Cite** by writing the token as the href of a markdown link on the claim span.
96
117
  Multiple citations are `;`-separated in one href.
97
118
 
@@ -109,7 +130,7 @@ bound 15 claim(s), 16 citation(s) [frontwalk]
109
130
  unresolved: 1
110
131
  overlap: pass 11, skip 4
111
132
  value-trace: pass 15
112
- ! unresolved: bd:t12-summary:p4.c1:1a2b
133
+ ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @2900
113
134
  wrote .backdraft/records/memo.backdraft.json
114
135
 
115
136
  $ echo $?
@@ -244,7 +265,11 @@ explicit, backdraft-scoped consent: `BACKDRAFT_VLM_API_KEY` (env or
244
265
  `OPENAI_API_KEY`/`OPENROUTER_API_KEY` are deliberately never read; a generic
245
266
  key in the environment is not consent to send documents to its provider. The
246
267
  default provider is OpenRouter running Gemini 3.1 Flash Lite; direct OpenAI is
247
- base_url + model, set explicitly. Without a scoped key, `auto` falls back to
268
+ base_url + model, set explicitly (`--config base_url=...` and `--config
269
+ model=...`, one key per flag). Every `--config` key is declared by the extractor
270
+ that reads it and checked against the one `ingest` chose, so a misspelled key
271
+ fails and names the keys that apply rather than being silently ignored.
272
+ Without a scoped key, `auto` falls back to
248
273
  the keyless text layer and says so — still storing each page's image, which it
249
274
  renders locally. PDF page rendering needs poppler, installed separately; without
250
275
  it ingest succeeds without the images and names `snapshot-pages` as the backfill.
@@ -0,0 +1,316 @@
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. The demo cites a web page, so the feature is visible
36
+
37
+ **Intent.** URL sources ship complete — `ingest` fetches and snapshots, the
38
+ registry keeps `url` and `fetched_at`, and as of 2026-08-06 the artifact links
39
+ back to the page with its fetch date. None of it appears at
40
+ `backdraft.dev/demo.html`, because both demo sources are local files
41
+ (`t12-summary.pdf`, `underwriting-model.xlsx`). The one artifact anybody
42
+ actually looks at does not show the capability, and the byte-identity test that
43
+ proved the change was safe is the same fact that keeps it invisible. A reader
44
+ evaluating backdraft cannot see that a web citation is a citation like any
45
+ other.
46
+
47
+ **Shape.** Add a third source to `demo/`: the Wikipedia article for the county
48
+ the property sits in, cited for market context — population, median household
49
+ income, the kind of external figure a screening memo genuinely leans on and
50
+ cannot get from a T12. Bridgeview Commons is fictional and says so in its own
51
+ first line, so give it a real county and let the memo name it; one new
52
+ paragraph under "The asset" or a short "Market" section, with one or two
53
+ claims. Cite Wikipedia's **permanent link** (`?oldid=<rev>`), not the bare
54
+ article URL: an article is edited constantly, a citation into the live URL
55
+ would report `drifted` within days, and a front-page demo that permanently
56
+ shows drift teaches that the tool is broken rather than that the source moved.
57
+ An immutable revision plus the rendered "as of" date is also what a careful
58
+ analyst cites. Write the tradeoff as a DESIGN row, naming the thing given up —
59
+ the demo no longer shows a live page changing under a citation, which is the
60
+ better story and belongs in a `drifted` walkthrough, not the shop window.
61
+
62
+ Practical notes for whoever builds this: `demo/.backdraft/` is gitignored, so
63
+ the fetch happens once on the implementer's machine and the snapshot then
64
+ travels in the local registry; `demo/generate_sources.py` builds the two
65
+ fixture files and is *not* where this goes, since the page is fetched rather
66
+ than generated. Re-bind and re-render (`bind memo.md --session s-bridgeview
67
+ --check value-trace,overlap`, then `render memo.md --to html`), and copy the
68
+ result over `site/demo.html` — the two are byte-identical by convention.
69
+ `demo/walkthrough.md` shows real CLI output and its `ls` and `bind` blocks both
70
+ move.
71
+
72
+ **Acceptance.** `backdraft.dev/demo.html` shows three sources, one of them a
73
+ clickable Wikipedia link with a fetch date, on both the resting rail and the
74
+ end-matter list; the claim citing it opens a card whose source line carries the
75
+ same link. `demo/walkthrough.md` matches what the commands now print. A re-bind
76
+ some weeks later still reports `resolved`, not `drifted` — that is the check
77
+ that the permanent link did its job.
78
+
79
+ **Size.** One day.
80
+
81
+ ### 2. The gate names a fetched source by its page, not by its staging filename
82
+
83
+ **Intent.** A fetched page's `filename` is `q4-2025.html`, a name invented by
84
+ `fetch.filename_for` for a temporary file that no longer exists. The
85
+ 2026-08-06 decision row ruled on this for the artifact — the URL *replaces* the
86
+ filename there, because "showing both would give a reader two names for one
87
+ thing and let the fictional one look authoritative" — but the same fictional
88
+ name is still what `backdraft read` prints in its document list, what its table
89
+ of contents headline prints, and what `ls` prints in column two. The gate is
90
+ where a writing agent actually lives, and there it learns nothing about
91
+ provenance at all: `skills/backdraft/SKILL.md` has to send it to `ls` for a
92
+ fact the surface it is already using could tell it. One rule, applied in one
93
+ place, is the fix.
94
+
95
+ **Shape.** `gate/reader.py`'s `render_documents` and `_document_headline`, plus
96
+ `cli.py`'s `ls` (which now shares the gate's page-count vocabulary through
97
+ `gate.unit` and should share this too). For a document whose `meta` carries a
98
+ `url`, show the origin in place of the filename, exactly as the artifact's
99
+ source list does. The gate's output is a contract — its module docstring says
100
+ "stable enough to diff" — so keep it line-oriented and keep the column
101
+ alignment: a URL is long, so put it on the row rather than in the aligned
102
+ filename column if alignment would collapse, and say which choice you made in
103
+ the docstring. `Document.meta` is already carried by `Registry.documents()` and
104
+ `Registry.document()`, so no registry change is needed. Do not touch the slug,
105
+ the token, or anything a citation resolves through — this is display, and the
106
+ 2026-08-05 row's "provenance, never identity" governs.
107
+
108
+ **Acceptance.** In a registry holding one file and one fetched page, `backdraft
109
+ read` shows the file by its filename and the page by its URL; `backdraft read
110
+ <slug>` for the fetched page carries the URL in its headline; `backdraft ls`
111
+ matches. A registry of files prints exactly what it printed before — pin that
112
+ as a test, the way the artifact's byte-identity is pinned. `README.md`, the
113
+ docs page and `skills/backdraft/SKILL.md` drop the detour through `ls` for a
114
+ fact `read` now carries.
115
+
116
+ **Size.** Two days.
117
+
118
+ ### 3. A fetched page's slug is decided, not defaulted
119
+
120
+ **Intent.** DESIGN.md's Open list has carried "slug assignment/collision rules"
121
+ since the first field trial, and URL sources sharpened it into a real failure:
122
+ `fetch.filename_for` takes the URL's last path segment, so
123
+ `https://a.example/reports/2025/index.html` and
124
+ `https://b.example/docs/index.html` both stage as `index.html` and become slugs
125
+ `index` and `index-2`. An agent then cites `bd:index-2:p1.c3:...` with no way to
126
+ tell which site that is, and a slug is the handle every token in every authored
127
+ document carries — it is stable once assigned, so a bad one is permanent. The
128
+ same collapse hits any site whose pages are `index.html`, `page`, `view`, or a
129
+ bare numeric id.
130
+
131
+ **Shape.** `fetch.filename_for`, and a decision row that closes the Open
132
+ question for files and URLs together. For a URL, build the stem from enough of
133
+ the address to distinguish it — the host plus the last meaningful path segment
134
+ is the obvious candidate, with the registry's existing `slug_for` slugification
135
+ doing the rest — and keep `_dedupe`'s `-2` as the collision *backstop* rather
136
+ than the normal outcome. `store._assign_slug` and `_dedupe` do not need to
137
+ change; this is about giving them a stem worth deduping. Note the constraint
138
+ that makes this delicate: continuity across a re-fetch is matched on the URL
139
+ (see `_find_document`), not on the slug, so changing the stem rule does not
140
+ break an existing registry's documents — but it does mean two registries built
141
+ on either side of this change disagree about a page's slug, which is a
142
+ compatibility statement the decision row has to make out loud.
143
+
144
+ **Acceptance.** The two `index.html` URLs above ingest as two slugs that each
145
+ name their site. `https://example.com/reports/q4-2025` still ingests as
146
+ `q4-2025` — the common case does not get uglier to fix the collision case. A
147
+ URL with an empty path still falls back to the host. Tests for each, plus one
148
+ that two pages colliding even after the new rule still dedupe rather than
149
+ error. DESIGN.md gains the decision row and the Open list loses the line.
150
+
151
+ **Size.** Two days.
152
+
153
+ ### 4. Ingest says what it did and what it got
154
+
155
+ **Intent.** Two things `ingest` knows and does not say. First, it prints the
156
+ same line whether it created a document, created a *new generation* of one
157
+ whose bytes changed, or did nothing at all because the bytes and config were
158
+ identical — so an agent re-ingesting after a fix cannot tell whether anything
159
+ happened, and the moment a new generation appears is exactly the moment
160
+ existing bindings may start reporting `drifted`. Second, it never says how much
161
+ text came out. `skills/backdraft/SKILL.md` instructs the agent that a
162
+ JavaScript-rendered page or one behind a login "will come back thin or empty,
163
+ and if it does, say so to the user rather than citing the shell of it" — and
164
+ gives it no signal to notice that with. A login wall extracts as a few dozen
165
+ characters, exits 0, and prints `1 page` like any success. The same silence
166
+ covers a scanned PDF with no text layer.
167
+
168
+ **Shape.** `ingest` in `cli.py`, output only — no registry or format change.
169
+ `Registry.ingest` already distinguishes the three outcomes internally
170
+ (`_find_document`, `_is_noop`, `_upsert_document`); surface which one happened
171
+ rather than re-deriving it in the CLI, which may mean returning it alongside
172
+ the `Document` or exposing it on the returned value. Print the extracted
173
+ character count beside the page count, and when it falls under a threshold the
174
+ module declares as a named constant, add one line naming the likely cause and
175
+ what to do — the shape the poppler and `pdf-text` notes already use, which is a
176
+ note at exit 0 and never a failure, because a thin page is still a real
177
+ snapshot and failures here are data. A new generation gets its own line saying
178
+ that citations into the previous one may now report `drifted`, and that `bind`
179
+ is what will say so.
180
+
181
+ **Acceptance.** Ingesting a file twice unchanged says so the second time;
182
+ editing it and re-ingesting says a new generation was made and names drift;
183
+ both still exit 0. A near-empty HTML file ingests, exits 0, and prints the thin
184
+ -source note; a normal source does not. `backdraft ingest` on a fresh document
185
+ prints what it prints today plus the character count. Tests for all five
186
+ branches. `demo/walkthrough.md`, `README.md` and `site/docs.html` show the real
187
+ new output, and `skills/backdraft/SKILL.md` points at the note instead of
188
+ asking the agent to judge thinness unaided.
189
+
190
+ **Size.** Two days.
191
+
192
+ ### 5. `backdraft verify <artifact>`: checking a receipt without the registry
193
+
194
+ **Intent.** `skills/backdraft-artifact/SKILL.md` exists because there is no
195
+ command: it walks an agent through opening a `.backdraft.json` or
196
+ `.backdraft.html`, recomputing each `snippet_sha256`, and reporting claim
197
+ statuses by hand. That is a deterministic check over a self-describing file,
198
+ which is precisely the kind of thing that should not be a prose procedure an
199
+ agent re-implements every time — and doing it by hand is how a recipient's
200
+ check ends up weaker than the producer's. The artifact is the product; nothing in the
201
+ tool reads one back.
202
+
203
+ **Shape.** A new command in `render/cli.py` (which already owns the artifact's
204
+ reader half through `sidecar.read`), taking the artifact or its sidecar.
205
+ Two tiers, and the output must be explicit about which ran. Tier one needs
206
+ nothing but the file: parse it against `kernel/artifact.py`'s format, recompute
207
+ every `snippet_sha256` from the snippet the file carries, and confirm the
208
+ `$format` and the claim/citation counts — this is the check a recipient with
209
+ only the file can make, and it catches an edited artifact. Tier two runs only
210
+ when a registry is discoverable: re-resolve each token through the same path
211
+ `bind` uses and report the closed status set. Exit 0 when everything checked
212
+ passed, 1 on a usage error, and — matching `bind` — 2 when something did not
213
+ verify, so a hook can gate on it. Read-only: it opens no session and mints
214
+ nothing, which is what distinguishes it from `read` and from `backdraft show`
215
+ (landed 2026-08-12), whose whole point is that showing mints — an audit must not
216
+ make its subject citable.
217
+
218
+ **Acceptance.** `backdraft verify demo/memo.backdraft.html` from outside
219
+ `demo/` reports tier one passing and says plainly that no registry was found,
220
+ exiting 0. From inside `demo/` it also re-resolves and reports the one
221
+ unresolved citation, exiting 2. A file with one snippet byte changed fails by
222
+ naming the claim and the hash that did not match. A file that is not an
223
+ artifact exits 1 saying so. `skills/backdraft-artifact/SKILL.md` is rewritten
224
+ around the command, keeping the prose procedure only as the fallback for an
225
+ agent that has no backdraft install.
226
+
227
+ **Size.** Three days.
228
+
229
+ ### 6. `spec/registry.md`: the export format is normative, or it is not a format
230
+
231
+ **Intent.** `backdraft export` writes `"$format": "backdraft/registry-v1"`, a
232
+ version string that promises a specification and does not have one. `spec/`
233
+ holds `artifact.md`, `tokens.md` and `chunking.md`, and the repo's own rule is
234
+ that another implementation reads those and nothing else — the export is the
235
+ only declared format outside that set. It is also the only complete, portable
236
+ representation of a registry, so it is what a second implementation, a
237
+ migration, or an audit would read. The gap is not theoretical: the 2026-08-05
238
+ work added a conditional `meta` key to every exported document, a format change
239
+ to a format with nothing to change.
240
+
241
+ **Shape.** A new `spec/registry.md` in the voice of `spec/artifact.md`:
242
+ normative prose, MUST/OPTIONAL where it means them, and written to be readable
243
+ without the code. Cover the top-level shape, the document entry including the
244
+ `meta` key and its `url`/`fetched_at` (OPTIONAL, present only for a fetched
245
+ source — the same conditional the artifact spec already states, for the same
246
+ compatibility reason), every extraction generation with `is_current`, pages
247
+ and cells, and anchors with their receipts. State what identity is and what it
248
+ is not, matching the concept table in `SPEC.md`. Say what a conforming reader
249
+ must do with a key it does not recognize. Pin it the way the artifact format is
250
+ pinned: a golden-file test over the demo registry's export, so the next
251
+ conditional key is a test failure rather than an undocumented change.
252
+
253
+ **Acceptance.** `spec/registry.md` describes every key `Registry.export`
254
+ actually emits — check that mechanically, with a test that walks the exported
255
+ JSON and fails on a key the spec does not name. `SPEC.md`'s file map and
256
+ `README.md`'s spec list gain it. Exporting the demo registry and reading only
257
+ the spec is enough to say what each field means.
258
+
259
+ **Size.** Two days.
260
+
261
+ ## Parked
262
+
263
+ Deliberately not queued, each with the reason, so picking one up starts from
264
+ the objection rather than rediscovering it.
265
+
266
+ - **Exhibits view** — the evidence-first inversion. Risk: it grows the
267
+ artifact and pollutes a design whose strength is restraint. Needs a
268
+ deliberate design pass and a real audience before any code; not a
269
+ weekend-feature shape.
270
+ - **Excel region maps and range reads** — the research notes exist, but no
271
+ confidence the deterministic detection cascade lands well on the first
272
+ try. Revisit when large-sheet navigation hurts in a real corpus, with
273
+ test workbooks in hand.
274
+ - **`bd:calc` derivations** — reserved in the grammar and parsed as
275
+ malformed-on-purpose, which is enough for now. Full support complicates
276
+ binding and verification for one field-trial case.
277
+ - **Merged-cell and frozen-pane rendering** — ingest captures `merged` and
278
+ `frozen` in sheet meta and keeps them (cheap, and capture is the part you
279
+ cannot backfill). Frozen-pane rendering is cut; merged-cell rendering
280
+ waits for a real sheet that renders wrong without it.
281
+
282
+ ## Later
283
+
284
+ - **Living documents** — re-ingest, re-bind, and a drift-first report: "these
285
+ claims cite figures that changed; cited-then vs. now." The primitives
286
+ (generations, `drifted`, the word-diff) all exist; the missing piece is a
287
+ diff-shaped report and a demo.
288
+ - **Substrates beyond the CLI** — SDK middleware for pipelines (the exit-code
289
+ and `--to json` contracts are already shaped for it), and a client-side
290
+ drag-drop viewer page (no upload — the file never leaves the reader's
291
+ machine).
292
+ - **Email (.eml)** — arrives in diligence folders alongside everything else;
293
+ identity questions (headers vs. body, attachments as child sources) deserve
294
+ a decision row before code. The web-page half of this item shipped
295
+ 2026-08-05; `document_meta` is where an email's provenance would land.
296
+ - **Anthropic-API provider for the VLM extractor** — Cowork's sandbox
297
+ egress allowlist includes api.anthropic.com but not the OpenAI-compatible
298
+ providers, so an Anthropic-native option would let sandboxed sessions
299
+ ingest at full fidelity. Needs a client abstraction the vlm module
300
+ currently doesn't have; the documented workaround (ingest locally, the
301
+ registry travels with the folder) covers it meanwhile.
302
+ - **Distribution** — published on PyPI; the repo ships
303
+ `.claude-plugin/plugin.json` and `marketplace.json`, so it installs as a
304
+ Claude Code plugin and self-hosts its marketplace, and the AGENTS.md
305
+ snippet is published in the README and site docs. The community-directory
306
+ submission went in 2026-07-29 and is awaiting review — approval is what
307
+ surfaces the skills in Cowork's directory.
308
+
309
+ ## Someday
310
+
311
+ - **Hosted team features** — upload, access control, shared registries.
312
+ Deliberately parked: the product's trust story is the self-contained file,
313
+ and a hosted viewer inverts it. Revisit only as a distinct teams product.
314
+ - **Entail at scale** — the model-judge verifier is wired (`[entail]` extra)
315
+ but has never been field-calibrated; needs a corpus and a rubric before it
316
+ is recommended anywhere.
@@ -231,11 +231,12 @@ class Extractor(Protocol):
231
231
  name: str
232
232
  version: str
233
233
  deterministic: bool
234
+ config_keys: Mapping[str, str] # optional; --config surface, key -> meaning
234
235
  def can_handle(self, path: Path, media_type: str) -> bool: ...
235
236
  def extract(self, path: Path, config: dict) -> Iterator[ExtractedPage]: ...
236
237
  ```
237
238
 
238
- Registered in a plain dict; `ingest --extractor auto` picks the first `can_handle`, built-ins ordered `xlsx, xls, csv, docx, pptx, pdf-text, image, html, text` (an extractor whose optional extra is missing is skipped, not fatal). `vlm` is pdf→images→VLM per page, OpenAI-compatible client, model configurable; its deps ship by default and the backdraft-scoped key gates use. **For PDFs, `auto` prefers `vlm` when it is ready (importable, key configured)** (glossy layouts and info boxes extract badly from the text layer, and the snapshot is the receipt); otherwise it falls back to `pdf-text` and prints a one-line nudge naming the VLM option. An explicit `--extractor` always wins. The sheet representation (shared by xlsx, xls, csv): `Row | A | B...` header, `[B10] value` prefixes, dimension caps, inflated-sheet placeholder pages; cell values are never rounded, since the snapshot is the receipt a claim is traced against. `docx` synthesizes pages from heading sections (the document's smallest present outline level among Heading 1–2 splits; no headings → one page), so locators stay `pN.cM`. `pptx` is one page per slide, text floor only; ingest notes that slide visuals need the PDF-export path. `pdf-text` reconstructs paragraph breaks from line geometry before handing the page over, so the chunker's blank-line rule has something to fire on. `html` is one page for the whole document, named by its `<title>`: block elements end a block so the chunker's blank-line rule fires, `script`/`style`/`noscript`/`template`/`svg` are dropped whole, lists become `- `/`1. ` lines in a single block, and tables become pipe tables as `docx` renders them. It is a parse and not a readability heuristic — no boilerplate stripping, since a guess that changes between two versions of a site moves anchors. Decoding is a pure function of the bytes (BOM, then `<meta charset>`, then UTF-8 with replacement), so a saved page and a fetched one extract identically.
239
+ Registered in a plain dict; `ingest --extractor auto` picks the first `can_handle`, built-ins ordered `xlsx, xls, csv, docx, pptx, pdf-text, image, html, text` (an extractor whose optional extra is missing is skipped, not fatal). `config_keys` declares the `--config` keys an extractor reads; `Registry.ingest` validates the parsed config against the *chosen* extractor (after selection, since `auto` picks per file) and raises on an undeclared key, naming it and the ones that apply. Declaring nothing is the default and means accepting nothing. The page-render keys (`dpi`, `snapshot_quality`, `snapshot_max_height`) are declared once in `extract/snapshots.py` and taken by both PDF paths, since both rasterize; the provider keys (`api_key`, `base_url`, `model`, `timeout`, `retries`) are declared in `extract/vlm_settings.py` and taken by both vision paths; `concurrency` is `vlm`'s alone. `vlm` is pdf→images→VLM per page, OpenAI-compatible client, model configurable; its deps ship by default and the backdraft-scoped key gates use. **For PDFs, `auto` prefers `vlm` when it is ready (importable, key configured)** (glossy layouts and info boxes extract badly from the text layer, and the snapshot is the receipt); otherwise it falls back to `pdf-text` and prints a one-line nudge naming the VLM option. An explicit `--extractor` always wins. The sheet representation (shared by xlsx, xls, csv): `Row | A | B...` header, `[B10] value` prefixes, dimension caps, inflated-sheet placeholder pages; cell values are never rounded, since the snapshot is the receipt a claim is traced against. `docx` synthesizes pages from heading sections (the document's smallest present outline level among Heading 1–2 splits; no headings → one page), so locators stay `pN.cM`. `pptx` is one page per slide, text floor only; ingest notes that slide visuals need the PDF-export path. `pdf-text` reconstructs paragraph breaks from line geometry before handing the page over, so the chunker's blank-line rule has something to fire on. `html` is one page for the whole document, named by its `<title>`: block elements end a block so the chunker's blank-line rule fires, `script`/`style`/`noscript`/`template`/`svg` are dropped whole, lists become `- `/`1. ` lines in a single block, and tables become pipe tables as `docx` renders them. It is a parse and not a readability heuristic — no boilerplate stripping, since a guess that changes between two versions of a site moves anchors. Decoding is a pure function of the bytes (BOM, then `<meta charset>`, then UTF-8 with replacement), so a saved page and a fetched one extract identically.
239
240
 
240
241
  ## Gate (gate/)
241
242
 
@@ -243,6 +244,7 @@ CLI-facing behavior contract:
243
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
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).
245
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
+ - `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.
246
248
  - Every emitted token is recorded to the ledger under the session (`--session` flag or `BACKDRAFT_SESSION` env; auto-created default session otherwise).
247
249
  - Long pages: `--offset/--limit` on chars for PDFs; sheets paginate by rows, never mid-row, header row repeated.
248
250
 
@@ -293,6 +295,7 @@ backdraft ingest <sources...> [--extractor auto] [--slug S] [--config k=v]
293
295
  # a source is a path or an http(s) URL
294
296
  backdraft ls | backdraft read ... # gate, above
295
297
  backdraft search "<query>" [--in slug]
298
+ backdraft show <token>... # gate: what does this token say?
296
299
  backdraft bind <doc.md> [--session S] [--check ...] [--mode ...]
297
300
  backdraft render <doc.md> [--to html|footnotes|json] [-o out] [--theme name|file]
298
301
  backdraft theme [list|show <name|file>]
@@ -327,6 +330,8 @@ Integration invariants (every workstream's tests must respect): any token the ga
327
330
 
328
331
  `registry/store.py` exposes exactly this surface. W1 implements it; W2/W3 consume it and MUST NOT implement anything under `registry/`; their tests use a lightweight fake implementing these names.
329
332
 
333
+ Beside the class, `registry` exports one module function, `current_at(registry, anchor) -> Anchor | None`: the current generation's anchor at `anchor`'s locator, or None if the locator is gone. It is the other half of drift — `resolve` finds what the writer saw, this finds what stands there now — and it lives here because both `bind` and the gate's `show` need it and the dependency rule forbids either importing the other. A function rather than a method so it composes the surface above without widening it, and so the fakes keep working unchanged.
334
+
330
335
  ```python
331
336
  class Registry:
332
337
  @classmethod
@@ -389,4 +394,4 @@ class Registry:
389
394
 
390
395
  ## Addendum B — CLI assembly
391
396
 
392
- 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. 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.
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.
@@ -339,7 +339,7 @@ bound 15 claim(s), 16 citation(s) [frontwalk]
339
339
  unresolved: 1
340
340
  overlap: pass 11, skip 4
341
341
  value-trace: pass 15
342
- ! unresolved: bd:t12-summary:p4.c1:1a2b
342
+ ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @2900
343
343
  wrote .backdraft/records/memo.backdraft.json
344
344
  exit: 2
345
345
  ```
@@ -351,6 +351,13 @@ the current extraction of their source. One did not, and it is named. Bind did
351
351
  not delete it, did not warn-and-continue, and did not quietly attach it to
352
352
  something nearby.
353
353
 
354
+ **The line item names the sentence, not just the token.** Exit 2 sends you back
355
+ into `memo.md` to fix something, and `bd:t12-summary:p4.c1:1a2b` is the one
356
+ thing you already had. The claim's own words follow it, then `@2900` — the
357
+ claim's character offset into the authored document, which is what tells two
358
+ line items carrying the same token apart when one token is cited from several
359
+ sentences.
360
+
354
361
  **Exit code 2 means unresolved citations.** Not a failed check — a citation that
355
362
  does not point at anything. It is the code a `Stop` hook or a CI job gates on.
356
363
  Exit 1 is a usage error; exit 0 means every citation resolved.
@@ -492,7 +499,29 @@ backdraft theme show default > ~/.config/backdraft/theme.toml
492
499
 
493
500
  ## 10. Fixing the failure
494
501
 
495
- The memo's broken token was a typo for a real anchor. Find it the same way you
502
+ Start by asking the token itself. `show` is the inverse of minting: hand it a
503
+ token from anywhere — a bind report, an artifact, a draft — and it says what
504
+ that token names.
505
+
506
+ ```bash
507
+ backdraft show bd:t12-summary:p4.c1:1a2b
508
+ echo "exit: $?"
509
+ ```
510
+
511
+ ```
512
+ [bd:t12-summary:p4.c1:1a2b] unresolved
513
+ t12-summary carries no anchor named by this token, in any extraction; the locator or the hash is wrong
514
+
515
+ [Table of contents: backdraft read t12-summary]
516
+ exit: 1
517
+ ```
518
+
519
+ The slug is real — that document was ingested — so what was invented is the page
520
+ or the hash. (A token whose source had *changed* rather than never existed would
521
+ come back `drifted`, printing both the snippet that was cited and the snippet
522
+ standing at that locator now.)
523
+
524
+ So the broken token was a typo for a real anchor. Find it the same way you
496
525
  found everything else:
497
526
 
498
527
  ```bash
@@ -512,8 +541,26 @@ backdraft search "replacement reserve"
512
541
  [Read the page: backdraft read t12-summary p3]
513
542
  ```
514
543
 
515
- `bd:t12-summary:p3.c4:6f0f` is the snippet that actually says it. Swapping it in
516
- and re-binding would take the run to `resolved: 16` and exit 0.
544
+ `bd:t12-summary:p3.c4:6f0f` is the snippet that actually says it — and `show`
545
+ confirms that before the edit, rather than after the re-bind:
546
+
547
+ ```bash
548
+ backdraft show bd:t12-summary:p3.c4:6f0f
549
+ ```
550
+
551
+ ```
552
+ [bd:t12-summary:p3.c4:6f0f] resolved t12-summary p3.c4
553
+ Capital expenditures of $317,000 were incurred outside the operating statement and are not deducted in arriving at
554
+ net operating income. The largest items were roof replacement on the A and B buildings ($186,000) and parking
555
+ lot resurfacing ($64,500). A replacement reserve of $250 per unit per year, or $32,000 annually, would be
556
+ customary for an asset of this vintage and is not reflected in the figures above.
557
+
558
+ [Read the page: backdraft read t12-summary p3]
559
+ ```
560
+
561
+ `show` is the gate like `read` and `search` are, so that snippet is now minted
562
+ into the session and the token is citable. Swapping it in and re-binding would
563
+ take the run to `resolved: 16` and exit 0.
517
564
 
518
565
  **This demo deliberately does not make that fix.** The checked-in `memo.md`
519
566
  and `memo.backdraft.html` both carry the unresolved citation, because an
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "backdraft"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  description = "Drop-in provenance for factual claims: one click from any claim to the evidence behind it."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"