backdraft 0.7.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. {backdraft-0.7.0 → backdraft-0.9.0}/.claude-plugin/plugin.json +1 -1
  2. {backdraft-0.7.0 → backdraft-0.9.0}/DESIGN.md +9 -3
  3. {backdraft-0.7.0 → backdraft-0.9.0}/PKG-INFO +72 -18
  4. {backdraft-0.7.0 → backdraft-0.9.0}/README.md +69 -17
  5. backdraft-0.9.0/ROADMAP.md +393 -0
  6. {backdraft-0.7.0 → backdraft-0.9.0}/SPEC.md +32 -12
  7. {backdraft-0.7.0 → backdraft-0.9.0}/demo/memo.backdraft.html +172 -89
  8. {backdraft-0.7.0 → backdraft-0.9.0}/demo/memo.md +14 -1
  9. {backdraft-0.7.0 → backdraft-0.9.0}/demo/walkthrough.md +201 -36
  10. {backdraft-0.7.0 → backdraft-0.9.0}/pyproject.toml +4 -1
  11. {backdraft-0.7.0 → backdraft-0.9.0}/site/demo.html +172 -89
  12. {backdraft-0.7.0 → backdraft-0.9.0}/site/docs.html +57 -18
  13. {backdraft-0.7.0 → backdraft-0.9.0}/site/llms.txt +57 -11
  14. {backdraft-0.7.0 → backdraft-0.9.0}/skills/backdraft/SKILL.md +67 -11
  15. backdraft-0.9.0/skills/backdraft-artifact/SKILL.md +161 -0
  16. {backdraft-0.7.0 → backdraft-0.9.0}/skills/backdraft-backfill/SKILL.md +9 -3
  17. {backdraft-0.7.0 → backdraft-0.9.0}/spec/artifact.md +24 -6
  18. backdraft-0.9.0/spec/registry.md +332 -0
  19. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/bind/binder.py +18 -24
  20. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/bind/cli.py +66 -19
  21. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/cli.py +123 -15
  22. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/cli_context.py +42 -3
  23. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/fetch.py +59 -6
  24. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/gate/__init__.py +2 -0
  25. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/gate/cli.py +4 -1
  26. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/gate/reader.py +52 -6
  27. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/gate/searcher.py +27 -4
  28. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/kernel/artifact.py +27 -7
  29. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/registry/__init__.py +10 -0
  30. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/registry/store.py +184 -20
  31. backdraft-0.9.0/src/backdraft/render/cli.py +468 -0
  32. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/html/assets.py +5 -0
  33. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/html/components.py +18 -0
  34. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/html/page.py +17 -7
  35. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/html/text.py +8 -1
  36. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/markdown.py +41 -9
  37. backdraft-0.9.0/src/backdraft/render/math.py +145 -0
  38. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/sidecar.py +55 -0
  39. {backdraft-0.7.0 → backdraft-0.9.0}/tests/gate/fake_registry.py +18 -4
  40. {backdraft-0.7.0 → backdraft-0.9.0}/tests/gate/test_gate_reader.py +89 -0
  41. {backdraft-0.7.0 → backdraft-0.9.0}/tests/gate/test_gate_searcher.py +81 -1
  42. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/render/backfill.sidecar.json +5 -4
  43. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/render/demo.sidecar.json +5 -4
  44. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_bind_cli.py +129 -1
  45. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_cli.py +189 -6
  46. backdraft-0.9.0/tests/test_docs.py +133 -0
  47. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_fetch.py +50 -0
  48. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_gate_integration.py +20 -0
  49. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_html.py +14 -1
  50. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_ingest_url.py +95 -8
  51. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_registry.py +72 -2
  52. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_reingest.py +12 -2
  53. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_render_cli.py +60 -1
  54. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_render_markdown.py +33 -0
  55. backdraft-0.9.0/tests/test_render_math.py +217 -0
  56. backdraft-0.9.0/tests/test_spec_registry.py +324 -0
  57. backdraft-0.9.0/tests/test_verify_cli.py +564 -0
  58. {backdraft-0.7.0 → backdraft-0.9.0}/uv.lock +15 -2
  59. backdraft-0.7.0/ROADMAP.md +0 -316
  60. backdraft-0.7.0/skills/backdraft-artifact/SKILL.md +0 -121
  61. backdraft-0.7.0/src/backdraft/render/cli.py +0 -184
  62. backdraft-0.7.0/tests/test_docs.py +0 -44
  63. {backdraft-0.7.0 → backdraft-0.9.0}/.claude-plugin/marketplace.json +0 -0
  64. {backdraft-0.7.0 → backdraft-0.9.0}/.github/workflows/ci.yml +0 -0
  65. {backdraft-0.7.0 → backdraft-0.9.0}/.github/workflows/release.yml +0 -0
  66. {backdraft-0.7.0 → backdraft-0.9.0}/.github/workflows/site.yml +0 -0
  67. {backdraft-0.7.0 → backdraft-0.9.0}/.gitignore +0 -0
  68. {backdraft-0.7.0 → backdraft-0.9.0}/.python-version +0 -0
  69. {backdraft-0.7.0 → backdraft-0.9.0}/LICENSE +0 -0
  70. {backdraft-0.7.0 → backdraft-0.9.0}/assets/backdraft-mark.svg +0 -0
  71. {backdraft-0.7.0 → backdraft-0.9.0}/demo/generate_sources.py +0 -0
  72. {backdraft-0.7.0 → backdraft-0.9.0}/demo/sources/t12-summary.pdf +0 -0
  73. {backdraft-0.7.0 → backdraft-0.9.0}/demo/sources/underwriting-model.xlsx +0 -0
  74. {backdraft-0.7.0 → backdraft-0.9.0}/site/index.html +0 -0
  75. {backdraft-0.7.0 → backdraft-0.9.0}/site/privacy.html +0 -0
  76. {backdraft-0.7.0 → backdraft-0.9.0}/spec/chunking.md +0 -0
  77. {backdraft-0.7.0 → backdraft-0.9.0}/spec/tokens.md +0 -0
  78. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/__init__.py +0 -0
  79. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/__main__.py +0 -0
  80. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/bind/__init__.py +0 -0
  81. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/bind/evidence.py +0 -0
  82. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/bind/verify/__init__.py +0 -0
  83. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/bind/verify/base.py +0 -0
  84. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/bind/verify/entail.py +0 -0
  85. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/bind/verify/overlap.py +0 -0
  86. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/bind/verify/recompute.py +0 -0
  87. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/bind/verify/value_trace.py +0 -0
  88. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/credentials.py +0 -0
  89. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/__init__.py +0 -0
  90. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/base.py +0 -0
  91. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/csv.py +0 -0
  92. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/docx.py +0 -0
  93. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/html.py +0 -0
  94. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/image.py +0 -0
  95. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/pdf_text.py +0 -0
  96. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/pptx.py +0 -0
  97. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/sheet.py +0 -0
  98. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/snapshots.py +0 -0
  99. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/text.py +0 -0
  100. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/vlm.py +0 -0
  101. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/vlm_settings.py +0 -0
  102. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/xls.py +0 -0
  103. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/extract/xlsx.py +0 -0
  104. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/kernel/__init__.py +0 -0
  105. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/kernel/chunking.py +0 -0
  106. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/kernel/claims.py +0 -0
  107. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/kernel/errors.py +0 -0
  108. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/kernel/hashing.py +0 -0
  109. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/kernel/model.py +0 -0
  110. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/kernel/tokens.py +0 -0
  111. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/registry/ledger.py +0 -0
  112. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/registry/schema.sql +0 -0
  113. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/__init__.py +0 -0
  114. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/_text.py +0 -0
  115. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/footnotes.py +0 -0
  116. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/html/__init__.py +0 -0
  117. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/html/fmt.py +0 -0
  118. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/html/fmt_vectors.py +0 -0
  119. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/placement.py +0 -0
  120. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/theme.py +0 -0
  121. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/themes/default.toml +0 -0
  122. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/themes/press.toml +0 -0
  123. {backdraft-0.7.0 → backdraft-0.9.0}/src/backdraft/render/themes/slate.toml +0 -0
  124. {backdraft-0.7.0 → backdraft-0.9.0}/tests/conftest.py +0 -0
  125. {backdraft-0.7.0 → backdraft-0.9.0}/tests/conftest_registry.py +0 -0
  126. {backdraft-0.7.0 → backdraft-0.9.0}/tests/conftest_render.py +0 -0
  127. {backdraft-0.7.0 → backdraft-0.9.0}/tests/css_util.py +0 -0
  128. {backdraft-0.7.0 → backdraft-0.9.0}/tests/fakes.py +0 -0
  129. {backdraft-0.7.0 → backdraft-0.9.0}/tests/gate/conftest.py +0 -0
  130. {backdraft-0.7.0 → backdraft-0.9.0}/tests/gate/test_gate_cli.py +0 -0
  131. {backdraft-0.7.0 → backdraft-0.9.0}/tests/gate/test_gate_ledger.py +0 -0
  132. {backdraft-0.7.0 → backdraft-0.9.0}/tests/gate/test_reader_cells.py +0 -0
  133. {backdraft-0.7.0 → backdraft-0.9.0}/tests/gate/test_reader_show.py +0 -0
  134. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/bind/backfill.bound.md +0 -0
  135. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/bind/frontwalk.bound.md +0 -0
  136. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/chunking/long_tail.json +0 -0
  137. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/chunking/long_tail.txt +0 -0
  138. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/chunking/many_small.json +0 -0
  139. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/chunking/many_small.txt +0 -0
  140. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/chunking/mixed.json +0 -0
  141. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/chunking/mixed.txt +0 -0
  142. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/chunking/one_giant.json +0 -0
  143. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/chunking/one_giant.txt +0 -0
  144. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/chunking/short_page.json +0 -0
  145. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/chunking/short_page.txt +0 -0
  146. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/chunking/unsplittable.json +0 -0
  147. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/chunking/unsplittable.txt +0 -0
  148. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/render/backfill.footnotes.md +0 -0
  149. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden/render/demo.footnotes.md +0 -0
  150. {backdraft-0.7.0 → backdraft-0.9.0}/tests/golden_util.py +0 -0
  151. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_bind_binder.py +0 -0
  152. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_bind_evidence.py +0 -0
  153. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_bind_verify.py +0 -0
  154. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_card_sizing.py +0 -0
  155. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_chunking.py +0 -0
  156. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_claims.py +0 -0
  157. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_credentials.py +0 -0
  158. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_extract.py +0 -0
  159. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_extract_html.py +0 -0
  160. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_fmt_parity.py +0 -0
  161. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_footnotes.py +0 -0
  162. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_hashing.py +0 -0
  163. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_invariants.py +0 -0
  164. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_model.py +0 -0
  165. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_placement.py +0 -0
  166. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_records_location.py +0 -0
  167. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_sidecar.py +0 -0
  168. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_snapshot_settings.py +0 -0
  169. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_snapshots.py +0 -0
  170. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_theme.py +0 -0
  171. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_tokens.py +0 -0
  172. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_value_trace.py +0 -0
  173. {backdraft-0.7.0 → backdraft-0.9.0}/tests/test_version.py +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "backdraft",
3
3
  "displayName": "Backdraft",
4
- "version": "0.7.0",
4
+ "version": "0.9.0",
5
5
  "description": "Drop-in provenance for factual claims: one click from any claim to the evidence behind it. Ships the backdraft writing, backfill, and artifact-reading skills; the CLI installs separately (uv tool install backdraft).",
6
6
  "author": {
7
7
  "name": "Spencer Braun",
@@ -29,7 +29,7 @@ Four verbs:
29
29
  - **`ingest`** — deterministically anchor source documents (PDF/xlsx/md/…) into the registry: extract, locate, snapshot snippets, hash.
30
30
  - **`read` / `search`** — the gate. Emit token-marked context for a model. Search results are themselves mintable evidence (search that returns nothing citable forces a page read purely to obtain an anchor).
31
31
  - **`bind`** — postprocess a written document: resolve tokens, run enabled verification switches, backfill surface forms (doc names, pages, quotes), emit sidecar claims file + binding report.
32
- - **`render`** — produce the clickable artifact.
32
+ - **`render`** — produce the clickable artifact. Its reader half is `verify`: an artifact carries the checks it wants run in its own legend, so the tool that writes them ships the command that runs them.
33
33
 
34
34
  ### 3. Substrates (sequenced)
35
35
  1. **Skill + CLI, together** (first release). The CLI is the system; the skill is ~a page instructing an agent to use it. Ships both the agent-native wedge and the zero-integration backfill demo.
@@ -143,6 +143,14 @@ 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. |
150
+ | 2026-08-24 | **`backdraft verify` — the recipient's check, and the drift bug it found in the spec.** The artifact's whole promise is that someone holding only the file can check it, and until now the instructions for doing so were prose: `skills/backdraft-artifact/SKILL.md` walked an agent through recomputing hashes by hand, which is a deterministic procedure re-implemented per reading — the way a recipient's audit ends up weaker than the producer's. It is now a command, in `render/cli.py` because that module already holds the format's reader half (`render.sidecar`), taking either the `.backdraft.html` or the `.backdraft.json`. Two tiers, and the output names which ran: the record against itself, which needs nothing but the file, and — only where a `.backdraft/` is discoverable — every token re-resolved against the sources. Discovery is from **cwd**, not from the artifact's own directory as `bind` does it from the document's: an artifact is a file people forward, so the folder it landed in is evidence about the recipient's machine and not about which registry produced it. Exit 2 joins `bind`'s, which was documented as "`bind`'s alone"; the shared meaning is "the run completed and the document is not clean", which is exactly what a Stop hook gates on, so a hook written for one now catches the other — the gate's `show` stays at 1, because a lookup answering "that token names nothing" is not a document failing. Read-only and minting nothing, which is the line between it and `show`: showing is minting, and an audit that made its own subject citable would manufacture the evidence it was sent to inspect. A record faithfully carrying an `unresolved` citation still passes tier one — a kept failure is the record working, so the same bytes exit 0 in an inbox and 2 in the project, answering different questions. Two consolidations came with it rather than a third copy of each: `registry.citation_for` is the token-to-status walk `bind` and `verify` both need and the dependency rule forbids either importing from the other, so it sits beside `current_at` for the same reason, with `not_shown` deliberately left out as a fact about a session the caller holds; and `cli_context.claim_words` is the line-item shape, since two commands reporting on the same claims must not print two layouts. The real find was in the spec. `spec/artifact.md` § Checking an artifact and the legend's `verify_this_record` both said the token's hash must prefix `anchor.snippet_sha256` — false for every `drifted` citation by construction, since the token names `drifted_from` (what the author cited) while `anchor` carries what stands there now, and the two differing *is* what drift means. Implemented as written, `verify` would have called every drifted artifact forged: the one status the format exists to carry honestly. Both statements are corrected, which moves the legend and therefore every artifact's bytes — goldens and the demo regenerated — and is worth the churn, because a known-wrong normative instruction shipped inside every artifact is a worse cost than a diff. |
151
+ | 2026-08-24 | **Math is rendered, not forbidden, and MathML is why that is affordable.** An agent writing a memo about coverage writes `$\mathrm{DSCR} = \frac{NOI}{D}$`, and the renderer handed the reader raw TeX — or worse, corrupted TeX, since `$x_1$ and $x_2$` paired its subscripts into emphasis and `\(a^2\)` lost its delimiters to the backslash-escape pass. The alternative on the table was to forbid math in the writing skill, and it was rejected twice over: this product's documents are exactly the finance and science memos that contain formulas, and a skill instruction cannot be enforced against an agent that did not read it carefully, so the corruption would have arrived anyway. Shipping *unrendered* TeX was rejected too — nobody wants to read `\frac{NOI}{D}`, and a document a reader cannot read is not a lesser artifact but a broken one. The target is MathML via `latex2mathml`, behind a `[math]` extra: it is static markup the browser lays out itself, so rules 1 and 2 of § The HTML artifact hold *by construction* rather than by care — no script, no font file, no request — where inlining KaTeX would have cost roughly a megabyte of fonts as `data:` URIs on every artifact, math or not. The tradeoff, stated because it is the one place the format's fixed-at-render promise is soft: MathML leans on whatever math font the reader's system has, so two readers of one artifact may see different glyph quality. That is a smaller and more honest cost than the alternative, and it is bounded — the markup, the spacing, and every character are identical; only the typeface is not. Dollars were the real hazard and the guard is Pandoc's: a memo saying `$250 per unit` and `$1.2M` twice in a paragraph would pair them into nonsense under a naive rule, so an opening `$` must be followed by a non-space and a closing `$` must be preceded by one and not followed by a digit — a currency amount is preceded by a space and therefore can never close a span. Code is skipped rather than scanned, so a shell snippet's `$PATH` and `$HOME` are safe, and the demo regenerates with zero math elements, which is the check that matters. Math is opt-in per call and **off for evidence**: a receipt's snippet must read as it was extracted, not as this renderer would prefer it. Conversion failure is data, at two different reliabilities — `latex2mathml` raises for some malformed input, which surfaces as a wavy mark at the formula and a note giving the reason, and silently degrades for the rest (`\notacommand{x}` becomes an identifier), which cannot be caught and is recorded here as a known limit rather than papered over. |
152
+ | 2026-08-25 | **The registry export is a specified format, or it is not a format.** `backdraft export` has written `"$format": "backdraft/registry-v1"` since v0 — a version string promising a specification that did not exist — while being the only complete, portable form a registry has, and therefore what a second implementation, a migration or an audit actually reads. The gap stopped being theoretical when URL sources added a conditional `meta` key to every exported document: a change to a format with nothing to change against. So spec/registry.md joins tokens, chunking and artifact as normative prose, and the pin is the spec itself rather than a golden file beside it — tests/test_spec_registry.py parses the spec's key tables, exports a registry built to exercise every branch, and fails in both directions, so an undocumented key and a documented-but-absent one are equally a test failure. Two boundaries were drawn rather than blurred. `bindings[].report` stays spec/artifact.md's, quoted opaquely here, because one format with two specifications is one too many and they would drift. And the export is content, not a backup: page images, sheet styling and the FTS index are deliberately outside it, so an export round-trips every citation and every receipt and does not round-trip a database — stated in the spec as a limit rather than left for a reader to discover by restoring one. |
153
+ | 2026-08-26 | **`search` reports the size of the answer, not the size of the page.** `--limit` defaults to 20 and the count line said `20 results` whether twenty matched or two hundred did, which is the silent cap this repo rejects everywhere else — `read` closes with `[Showing 0-464 of 3115 chars. Continue with: …]` for exactly this reason. Search is the surface an agent leans on hardest, since it is how a claim finds its evidence, and relevance order is not the same as the sentence the writer needed: a reader told it has twenty results picks the best of twenty and never learns the twenty-first existed. So `SearchResults` gains `total` beside `phrase_fallback` — the same shape for the same reason, an out-of-band fact riding on Addendum A's pinned `list[SearchHit]` so every consumer and every fake is untouched — and a capped run renders `2 of 56 results` plus a closing line naming the exact widening command, shell-quoted, because a hint carrying `$` or a space that has to be repaired before it runs is not a hint. The count is a `COUNT(*)` over the *same* joins the fetch runs, shared through `_match_sql` rather than written twice: a count that joined differently would report a total the caller can never reach. It is asked for only when the page came back full, since a page the limit did not fill is already the whole answer — so the common uncapped run still runs one query, and the output it prints is byte-identical to what it printed before, pinned as a test the way the artifact's bytes are. Two things were deliberately not built. No `--offset`: paging a relevance-ordered list is a different design question, and `--limit` already exists for the hint to name. And the total is not carried into anything durable — no token, anchor, receipt or artifact byte moves, because how many other sentences also matched is a fact about a query, not about a citation. |
146
154
 
147
155
  ## Open
148
156
 
@@ -150,8 +158,6 @@ Queued and parked work lives in ROADMAP.md; this list is questions still
150
158
  awaiting a decision:
151
159
 
152
160
  - 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
161
  - Distribution: published to PyPI as `backdraft` 0.2.0 (2026-07-28); domain
156
162
  backdraft.dev acquired; still open: skill registration, deploying site/
157
163
  - 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.9.0
4
4
  Summary: Drop-in provenance for factual claims: one click from any claim to the evidence behind it.
5
5
  Project-URL: Homepage, https://backdraft.dev
6
6
  Project-URL: Documentation, https://backdraft.dev/docs.html
@@ -18,6 +18,8 @@ Requires-Dist: python-pptx>=1.0
18
18
  Requires-Dist: typer>=0.12
19
19
  Provides-Extra: entail
20
20
  Requires-Dist: anthropic>=0.40; extra == 'entail'
21
+ Provides-Extra: math
22
+ Requires-Dist: latex2mathml>=3.77; extra == 'math'
21
23
  Provides-Extra: vlm
22
24
  Provides-Extra: xls
23
25
  Requires-Dist: python-calamine>=0.3; extra == 'xls'
@@ -67,11 +69,27 @@ documents: 0
67
69
  … plus where settings live and what to do next
68
70
 
69
71
  $ 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
72
+ t12-summary t12-summary.pdf pdf 3 pages 8865 chars
73
+ underwriting-model underwriting-model.xlsx xlsx 2 sheets 2357 chars
72
74
  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.
75
+
76
+ $ backdraft ingest "https://en.wikipedia.org/w/index.php?title=Franklin_County,_Ohio&oldid=1367935775" --slug franklin-county
77
+ franklin-county https://en.wikipedia.org/w/index.php?title=Franklin_County,_Ohio&oldid=1367935775 html 1 page 34141 chars
73
78
  ```
74
79
 
80
+ The demo cites that page for market context. It is Wikipedia's *permanent
81
+ link* — `?oldid=` serves one revision's bytes forever — because a citation into
82
+ a live article reports `drifted` the moment somebody edits it, and the quoted
83
+ sentence would no longer be the sentence there.
84
+
85
+ Every ingest line ends with how much text came out, and says which of three
86
+ things happened: a document created, a **new generation** of one whose bytes
87
+ moved — which is when citations into the previous snapshot start reporting
88
+ `drifted` — or `unchanged`, a no-op because re-running would reproduce what is
89
+ already there. When almost no text came out, a note names the likely cause (a
90
+ scan with no text layer, a page behind a login) and what to do; it is a note at
91
+ exit 0, because a thin snapshot is still a real one.
92
+
75
93
  **Read.** The page arrives with a citable name over each chunk. This is the whole
76
94
  mechanism: what you can cite is exactly what you were shown.
77
95
 
@@ -112,6 +130,11 @@ $ backdraft search "24850000"
112
130
  [Read the page: backdraft read underwriting-model p2]
113
131
  ```
114
132
 
133
+ Search shows twenty results by default. A run that hit the limit says so —
134
+ `20 of 56 results` — and closes with the command that shows the rest, because
135
+ "20 results" over an answer of 56 is how a reader concludes it has seen
136
+ everything when it has seen a third.
137
+
115
138
  A cell you can already see mints directly:
116
139
 
117
140
  ```console
@@ -150,12 +173,13 @@ Multiple citations are `;`-separated in one href.
150
173
 
151
174
  ```console
152
175
  $ backdraft bind memo.md --session s-bridgeview --check value-trace,overlap
153
- bound 15 claim(s), 16 citation(s) [frontwalk]
154
- resolved: 15
176
+ bound 17 claim(s), 18 citation(s) [frontwalk]
177
+ resolved: 17
155
178
  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
179
+ overlap: pass 13, skip 4
180
+ skip 4 — wording overlap does not apply to a single cell
181
+ value-trace: pass 17
182
+ ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @3629
159
183
  wrote .backdraft/records/memo.backdraft.json
160
184
 
161
185
  $ echo $?
@@ -177,6 +201,29 @@ memo.backdraft.html
177
201
  That file is the whole deliverable: send it over email or Slack and the
178
202
  recipient gets the full experience by double-clicking it.
179
203
 
204
+ **The recipient can check it.** `backdraft verify` takes the artifact or its
205
+ sidecar and does the checking that the file is built to make possible: every
206
+ snippet rehashed against the sha256 recorded beside it, every token checked
207
+ against the anchor it names, `summary` recounted from the claims.
208
+
209
+ ```console
210
+ $ backdraft verify memo.backdraft.html
211
+ checked memo.backdraft.html [backdraft/artifact-v1]
212
+ receipts: 17 of 17 hold
213
+ record: 17 claim(s), 18 citation(s); the summary recount agrees
214
+ recorded: resolved 17, unresolved 1
215
+ sources: no .backdraft/ found from here — not re-checked
216
+ [Re-check against the sources: run this inside the project it was bound in.]
217
+ ```
218
+
219
+ That first tier needs nothing but the file, which is the point — it is the check
220
+ someone who was emailed the artifact can still make. Run it inside the project
221
+ the document was bound in and it also re-resolves every citation against the
222
+ registry, so a source that has changed since shows up as `drifted` rather than
223
+ going unnoticed. Exit 0 when everything it checked passed, 2 when something did
224
+ not, so a hook can gate on it. It opens no session and mints nothing: an audit
225
+ must not make its subject citable.
226
+
180
227
  **Theming.** The artifact ships with a default look and two alternates,
181
228
  `press` and `slate`:
182
229
 
@@ -244,18 +291,23 @@ address.
244
291
 
245
292
  ```console
246
293
  $ backdraft ingest https://example.com/reports/q4-2025
247
- q4-2025 q4-2025.html html 1 page
294
+ q4-2025 https://example.com/reports/q4-2025 html 1 page 18402 chars
248
295
 
249
296
  $ backdraft ls
250
- q4-2025 q4-2025.html html 1 page https://example.com/reports/q4-2025
297
+ q4-2025 https://example.com/reports/q4-2025 html 1 page
251
298
  ```
252
299
 
300
+ Every surface names a fetched source by its page. The filename you never see —
301
+ `q4-2025.html` — is the temporary file the fetch staged the bytes in, and it
302
+ exists on nobody's disk, so `ingest`, `ls`, `backdraft read` and the artifact
303
+ all show the URL in its place rather than beside it: two names for one thing
304
+ would let the invented one look authoritative.
305
+
253
306
  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.
307
+ the URL as a link on its receipt, with the date the bytes were taken. That
308
+ pointer is the half of citing a web page a frozen receipt cannot answer on its
309
+ own: the snippet says what the page said, the link is how a reader asks whether
310
+ it still says it.
259
311
 
260
312
  What it does not do, stated rather than worked around: JavaScript-rendered
261
313
  pages give you whatever the server sends to a plain GET, pages behind a login
@@ -299,12 +351,13 @@ the keyless text layer and says so — still storing each page's image, which it
299
351
  renders locally. PDF page rendering needs poppler, installed separately; without
300
352
  it ingest succeeds without the images and names `snapshot-pages` as the backfill.
301
353
 
302
- Two extras:
354
+ Three extras:
303
355
 
304
356
  | Extra | Adds | For |
305
357
  |---|---|---|
306
358
  | `[entail]` | `anthropic` | `bind --check entail`, the model-judge verifier |
307
359
  | `[xls]` | `python-calamine` | legacy `.xls` workbooks, values only, rendered the same shape as xlsx |
360
+ | `[math]` | `latex2mathml` | LaTeX in a document becomes MathML in the artifact; without it, formulas render verbatim and `render` says so |
308
361
 
309
362
  `backdraft[vlm]` still installs: it is an empty compat alias from when the
310
363
  vision deps were an extra.
@@ -325,7 +378,7 @@ Three agent skills ship inside the package and in this repo:
325
378
  |---|---|
326
379
  | [`backdraft`](skills/backdraft/SKILL.md) | writing a new document from sources, citing as it goes |
327
380
  | [`backdraft-backfill`](skills/backdraft-backfill/SKILL.md) | attributing a document that already exists |
328
- | [`backdraft-artifact`](skills/backdraft-artifact/SKILL.md) | reading someone else's artifact cold |
381
+ | [`backdraft-artifact`](skills/backdraft-artifact/SKILL.md) | checking and reading someone else's artifact cold |
329
382
 
330
383
  The CLI is the system; a skill is one page telling an agent to use it. Nothing in
331
384
  the skills is required to use backdraft by hand. How the skills reach your agent
@@ -383,9 +436,10 @@ spans are citable. In a sandboxed session run every command as
383
436
  | [spec/tokens.md](spec/tokens.md) | the citation token grammar, normatively |
384
437
  | [spec/chunking.md](spec/chunking.md) | the deterministic chunker |
385
438
  | [spec/artifact.md](spec/artifact.md) | the artifact format, sidecar payload, legend, HTML rules |
439
+ | [spec/registry.md](spec/registry.md) | the registry export format `backdraft export` writes |
386
440
  | [demo/walkthrough.md](demo/walkthrough.md) | the whole thing end to end, with real output |
387
441
 
388
- The three files under `spec/` are the portable specification: another
442
+ The four files under `spec/` are the portable specification: another
389
443
  implementation reads them and nothing else.
390
444
 
391
445
  ## Status
@@ -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
 
@@ -87,6 +103,11 @@ $ backdraft search "24850000"
87
103
  [Read the page: backdraft read underwriting-model p2]
88
104
  ```
89
105
 
106
+ Search shows twenty results by default. A run that hit the limit says so —
107
+ `20 of 56 results` — and closes with the command that shows the rest, because
108
+ "20 results" over an answer of 56 is how a reader concludes it has seen
109
+ everything when it has seen a third.
110
+
90
111
  A cell you can already see mints directly:
91
112
 
92
113
  ```console
@@ -125,12 +146,13 @@ Multiple citations are `;`-separated in one href.
125
146
 
126
147
  ```console
127
148
  $ backdraft bind memo.md --session s-bridgeview --check value-trace,overlap
128
- bound 15 claim(s), 16 citation(s) [frontwalk]
129
- resolved: 15
149
+ bound 17 claim(s), 18 citation(s) [frontwalk]
150
+ resolved: 17
130
151
  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
152
+ overlap: pass 13, skip 4
153
+ skip 4 — wording overlap does not apply to a single cell
154
+ value-trace: pass 17
155
+ ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @3629
134
156
  wrote .backdraft/records/memo.backdraft.json
135
157
 
136
158
  $ echo $?
@@ -152,6 +174,29 @@ memo.backdraft.html
152
174
  That file is the whole deliverable: send it over email or Slack and the
153
175
  recipient gets the full experience by double-clicking it.
154
176
 
177
+ **The recipient can check it.** `backdraft verify` takes the artifact or its
178
+ sidecar and does the checking that the file is built to make possible: every
179
+ snippet rehashed against the sha256 recorded beside it, every token checked
180
+ against the anchor it names, `summary` recounted from the claims.
181
+
182
+ ```console
183
+ $ backdraft verify memo.backdraft.html
184
+ checked memo.backdraft.html [backdraft/artifact-v1]
185
+ receipts: 17 of 17 hold
186
+ record: 17 claim(s), 18 citation(s); the summary recount agrees
187
+ recorded: resolved 17, unresolved 1
188
+ sources: no .backdraft/ found from here — not re-checked
189
+ [Re-check against the sources: run this inside the project it was bound in.]
190
+ ```
191
+
192
+ That first tier needs nothing but the file, which is the point — it is the check
193
+ someone who was emailed the artifact can still make. Run it inside the project
194
+ the document was bound in and it also re-resolves every citation against the
195
+ registry, so a source that has changed since shows up as `drifted` rather than
196
+ going unnoticed. Exit 0 when everything it checked passed, 2 when something did
197
+ not, so a hook can gate on it. It opens no session and mints nothing: an audit
198
+ must not make its subject citable.
199
+
155
200
  **Theming.** The artifact ships with a default look and two alternates,
156
201
  `press` and `slate`:
157
202
 
@@ -219,18 +264,23 @@ address.
219
264
 
220
265
  ```console
221
266
  $ backdraft ingest https://example.com/reports/q4-2025
222
- q4-2025 q4-2025.html html 1 page
267
+ q4-2025 https://example.com/reports/q4-2025 html 1 page 18402 chars
223
268
 
224
269
  $ backdraft ls
225
- q4-2025 q4-2025.html html 1 page https://example.com/reports/q4-2025
270
+ q4-2025 https://example.com/reports/q4-2025 html 1 page
226
271
  ```
227
272
 
273
+ Every surface names a fetched source by its page. The filename you never see —
274
+ `q4-2025.html` — is the temporary file the fetch staged the bytes in, and it
275
+ exists on nobody's disk, so `ingest`, `ls`, `backdraft read` and the artifact
276
+ all show the URL in its place rather than beside it: two names for one thing
277
+ would let the invented one look authoritative.
278
+
228
279
  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.
280
+ the URL as a link on its receipt, with the date the bytes were taken. That
281
+ pointer is the half of citing a web page a frozen receipt cannot answer on its
282
+ own: the snippet says what the page said, the link is how a reader asks whether
283
+ it still says it.
234
284
 
235
285
  What it does not do, stated rather than worked around: JavaScript-rendered
236
286
  pages give you whatever the server sends to a plain GET, pages behind a login
@@ -274,12 +324,13 @@ the keyless text layer and says so — still storing each page's image, which it
274
324
  renders locally. PDF page rendering needs poppler, installed separately; without
275
325
  it ingest succeeds without the images and names `snapshot-pages` as the backfill.
276
326
 
277
- Two extras:
327
+ Three extras:
278
328
 
279
329
  | Extra | Adds | For |
280
330
  |---|---|---|
281
331
  | `[entail]` | `anthropic` | `bind --check entail`, the model-judge verifier |
282
332
  | `[xls]` | `python-calamine` | legacy `.xls` workbooks, values only, rendered the same shape as xlsx |
333
+ | `[math]` | `latex2mathml` | LaTeX in a document becomes MathML in the artifact; without it, formulas render verbatim and `render` says so |
283
334
 
284
335
  `backdraft[vlm]` still installs: it is an empty compat alias from when the
285
336
  vision deps were an extra.
@@ -300,7 +351,7 @@ Three agent skills ship inside the package and in this repo:
300
351
  |---|---|
301
352
  | [`backdraft`](skills/backdraft/SKILL.md) | writing a new document from sources, citing as it goes |
302
353
  | [`backdraft-backfill`](skills/backdraft-backfill/SKILL.md) | attributing a document that already exists |
303
- | [`backdraft-artifact`](skills/backdraft-artifact/SKILL.md) | reading someone else's artifact cold |
354
+ | [`backdraft-artifact`](skills/backdraft-artifact/SKILL.md) | checking and reading someone else's artifact cold |
304
355
 
305
356
  The CLI is the system; a skill is one page telling an agent to use it. Nothing in
306
357
  the skills is required to use backdraft by hand. How the skills reach your agent
@@ -358,9 +409,10 @@ spans are citable. In a sandboxed session run every command as
358
409
  | [spec/tokens.md](spec/tokens.md) | the citation token grammar, normatively |
359
410
  | [spec/chunking.md](spec/chunking.md) | the deterministic chunker |
360
411
  | [spec/artifact.md](spec/artifact.md) | the artifact format, sidecar payload, legend, HTML rules |
412
+ | [spec/registry.md](spec/registry.md) | the registry export format `backdraft export` writes |
361
413
  | [demo/walkthrough.md](demo/walkthrough.md) | the whole thing end to end, with real output |
362
414
 
363
- The three files under `spec/` are the portable specification: another
415
+ The four files under `spec/` are the portable specification: another
364
416
  implementation reads them and nothing else.
365
417
 
366
418
  ## Status