backdraft 0.5.0__tar.gz → 0.6.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 (168) hide show
  1. {backdraft-0.5.0 → backdraft-0.6.0}/.claude-plugin/plugin.json +1 -1
  2. backdraft-0.6.0/.github/workflows/site.yml +36 -0
  3. {backdraft-0.5.0 → backdraft-0.6.0}/DESIGN.md +5 -0
  4. {backdraft-0.5.0 → backdraft-0.6.0}/PKG-INFO +64 -4
  5. {backdraft-0.5.0 → backdraft-0.6.0}/README.md +63 -3
  6. backdraft-0.6.0/ROADMAP.md +258 -0
  7. {backdraft-0.5.0 → backdraft-0.6.0}/SPEC.md +25 -8
  8. {backdraft-0.5.0 → backdraft-0.6.0}/demo/memo.backdraft.html +38 -19
  9. {backdraft-0.5.0 → backdraft-0.6.0}/demo/walkthrough.md +20 -1
  10. {backdraft-0.5.0 → backdraft-0.6.0}/pyproject.toml +1 -1
  11. {backdraft-0.5.0 → backdraft-0.6.0}/site/demo.html +38 -19
  12. {backdraft-0.5.0 → backdraft-0.6.0}/site/docs.html +94 -7
  13. {backdraft-0.5.0 → backdraft-0.6.0}/site/index.html +7 -3
  14. {backdraft-0.5.0 → backdraft-0.6.0}/site/llms.txt +14 -1
  15. {backdraft-0.5.0 → backdraft-0.6.0}/skills/backdraft/SKILL.md +11 -0
  16. {backdraft-0.5.0 → backdraft-0.6.0}/skills/backdraft-artifact/SKILL.md +7 -0
  17. {backdraft-0.5.0 → backdraft-0.6.0}/skills/backdraft-backfill/SKILL.md +1 -0
  18. {backdraft-0.5.0 → backdraft-0.6.0}/spec/artifact.md +29 -8
  19. backdraft-0.6.0/src/backdraft/__init__.py +38 -0
  20. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/bind/evidence.py +27 -8
  21. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/cli.py +82 -36
  22. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/extract/base.py +2 -1
  23. backdraft-0.6.0/src/backdraft/extract/html.py +331 -0
  24. backdraft-0.6.0/src/backdraft/fetch.py +188 -0
  25. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/gate/__init__.py +2 -0
  26. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/gate/reader.py +11 -4
  27. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/kernel/artifact.py +4 -1
  28. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/kernel/model.py +12 -2
  29. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/registry/schema.sql +6 -1
  30. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/registry/store.py +117 -34
  31. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/render/__init__.py +4 -0
  32. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/render/_text.py +18 -1
  33. backdraft-0.6.0/src/backdraft/render/cli.py +184 -0
  34. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/render/footnotes.py +27 -8
  35. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/render/html/__init__.py +5 -2
  36. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/render/html/assets.py +73 -16
  37. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/render/html/components.py +32 -7
  38. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/render/html/page.py +23 -29
  39. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/render/html/text.py +27 -0
  40. backdraft-0.6.0/src/backdraft/render/theme.py +372 -0
  41. backdraft-0.6.0/src/backdraft/render/themes/default.toml +44 -0
  42. backdraft-0.6.0/src/backdraft/render/themes/press.toml +28 -0
  43. backdraft-0.6.0/src/backdraft/render/themes/slate.toml +32 -0
  44. backdraft-0.6.0/tests/conftest.py +25 -0
  45. backdraft-0.6.0/tests/css_util.py +66 -0
  46. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/render/backfill.sidecar.json +1 -1
  47. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/render/demo.sidecar.json +1 -1
  48. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_bind_evidence.py +52 -0
  49. backdraft-0.6.0/tests/test_card_sizing.py +107 -0
  50. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_cli.py +27 -2
  51. backdraft-0.6.0/tests/test_extract_html.py +249 -0
  52. backdraft-0.6.0/tests/test_fetch.py +188 -0
  53. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_footnotes.py +48 -0
  54. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_html.py +129 -6
  55. backdraft-0.6.0/tests/test_ingest_url.py +354 -0
  56. backdraft-0.6.0/tests/test_render_cli.py +330 -0
  57. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_snapshots.py +1 -1
  58. backdraft-0.6.0/tests/test_theme.py +283 -0
  59. backdraft-0.6.0/tests/test_version.py +46 -0
  60. {backdraft-0.5.0 → backdraft-0.6.0}/uv.lock +1 -1
  61. backdraft-0.5.0/.github/workflows/site.yml +0 -26
  62. backdraft-0.5.0/ROADMAP.md +0 -177
  63. backdraft-0.5.0/src/backdraft/__init__.py +0 -13
  64. backdraft-0.5.0/src/backdraft/render/cli.py +0 -107
  65. backdraft-0.5.0/tests/conftest.py +0 -9
  66. backdraft-0.5.0/tests/test_render_cli.py +0 -161
  67. {backdraft-0.5.0 → backdraft-0.6.0}/.claude-plugin/marketplace.json +0 -0
  68. {backdraft-0.5.0 → backdraft-0.6.0}/.github/workflows/ci.yml +0 -0
  69. {backdraft-0.5.0 → backdraft-0.6.0}/.github/workflows/release.yml +0 -0
  70. {backdraft-0.5.0 → backdraft-0.6.0}/.gitignore +0 -0
  71. {backdraft-0.5.0 → backdraft-0.6.0}/.python-version +0 -0
  72. {backdraft-0.5.0 → backdraft-0.6.0}/LICENSE +0 -0
  73. {backdraft-0.5.0 → backdraft-0.6.0}/assets/backdraft-mark.svg +0 -0
  74. {backdraft-0.5.0 → backdraft-0.6.0}/demo/generate_sources.py +0 -0
  75. {backdraft-0.5.0 → backdraft-0.6.0}/demo/memo.md +0 -0
  76. {backdraft-0.5.0 → backdraft-0.6.0}/demo/sources/t12-summary.pdf +0 -0
  77. {backdraft-0.5.0 → backdraft-0.6.0}/demo/sources/underwriting-model.xlsx +0 -0
  78. {backdraft-0.5.0 → backdraft-0.6.0}/site/privacy.html +0 -0
  79. {backdraft-0.5.0 → backdraft-0.6.0}/spec/chunking.md +0 -0
  80. {backdraft-0.5.0 → backdraft-0.6.0}/spec/tokens.md +0 -0
  81. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/__main__.py +0 -0
  82. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/bind/__init__.py +0 -0
  83. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/bind/binder.py +0 -0
  84. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/bind/cli.py +0 -0
  85. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/bind/verify/__init__.py +0 -0
  86. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/bind/verify/base.py +0 -0
  87. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/bind/verify/entail.py +0 -0
  88. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/bind/verify/overlap.py +0 -0
  89. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/bind/verify/recompute.py +0 -0
  90. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/bind/verify/value_trace.py +0 -0
  91. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/cli_context.py +0 -0
  92. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/credentials.py +0 -0
  93. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/extract/__init__.py +0 -0
  94. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/extract/csv.py +0 -0
  95. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/extract/docx.py +0 -0
  96. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/extract/image.py +0 -0
  97. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/extract/pdf_text.py +0 -0
  98. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/extract/pptx.py +0 -0
  99. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/extract/sheet.py +0 -0
  100. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/extract/snapshots.py +0 -0
  101. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/extract/text.py +0 -0
  102. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/extract/vlm.py +0 -0
  103. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/extract/vlm_settings.py +0 -0
  104. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/extract/xls.py +0 -0
  105. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/extract/xlsx.py +0 -0
  106. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/gate/cli.py +0 -0
  107. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/gate/searcher.py +0 -0
  108. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/kernel/__init__.py +0 -0
  109. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/kernel/chunking.py +0 -0
  110. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/kernel/claims.py +0 -0
  111. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/kernel/errors.py +0 -0
  112. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/kernel/hashing.py +0 -0
  113. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/kernel/tokens.py +0 -0
  114. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/registry/__init__.py +0 -0
  115. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/registry/ledger.py +0 -0
  116. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/render/html/fmt.py +0 -0
  117. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/render/html/fmt_vectors.py +0 -0
  118. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/render/markdown.py +0 -0
  119. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/render/placement.py +0 -0
  120. {backdraft-0.5.0 → backdraft-0.6.0}/src/backdraft/render/sidecar.py +0 -0
  121. {backdraft-0.5.0 → backdraft-0.6.0}/tests/conftest_registry.py +0 -0
  122. {backdraft-0.5.0 → backdraft-0.6.0}/tests/conftest_render.py +0 -0
  123. {backdraft-0.5.0 → backdraft-0.6.0}/tests/fakes.py +0 -0
  124. {backdraft-0.5.0 → backdraft-0.6.0}/tests/gate/conftest.py +0 -0
  125. {backdraft-0.5.0 → backdraft-0.6.0}/tests/gate/fake_registry.py +0 -0
  126. {backdraft-0.5.0 → backdraft-0.6.0}/tests/gate/test_gate_cli.py +0 -0
  127. {backdraft-0.5.0 → backdraft-0.6.0}/tests/gate/test_gate_ledger.py +0 -0
  128. {backdraft-0.5.0 → backdraft-0.6.0}/tests/gate/test_gate_reader.py +0 -0
  129. {backdraft-0.5.0 → backdraft-0.6.0}/tests/gate/test_gate_searcher.py +0 -0
  130. {backdraft-0.5.0 → backdraft-0.6.0}/tests/gate/test_reader_cells.py +0 -0
  131. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/bind/backfill.bound.md +0 -0
  132. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/bind/frontwalk.bound.md +0 -0
  133. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/chunking/long_tail.json +0 -0
  134. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/chunking/long_tail.txt +0 -0
  135. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/chunking/many_small.json +0 -0
  136. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/chunking/many_small.txt +0 -0
  137. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/chunking/mixed.json +0 -0
  138. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/chunking/mixed.txt +0 -0
  139. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/chunking/one_giant.json +0 -0
  140. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/chunking/one_giant.txt +0 -0
  141. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/chunking/short_page.json +0 -0
  142. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/chunking/short_page.txt +0 -0
  143. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/chunking/unsplittable.json +0 -0
  144. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/chunking/unsplittable.txt +0 -0
  145. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/render/backfill.footnotes.md +0 -0
  146. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden/render/demo.footnotes.md +0 -0
  147. {backdraft-0.5.0 → backdraft-0.6.0}/tests/golden_util.py +0 -0
  148. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_bind_binder.py +0 -0
  149. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_bind_cli.py +0 -0
  150. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_bind_verify.py +0 -0
  151. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_chunking.py +0 -0
  152. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_claims.py +0 -0
  153. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_credentials.py +0 -0
  154. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_extract.py +0 -0
  155. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_fmt_parity.py +0 -0
  156. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_gate_integration.py +0 -0
  157. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_hashing.py +0 -0
  158. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_invariants.py +0 -0
  159. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_model.py +0 -0
  160. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_placement.py +0 -0
  161. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_records_location.py +0 -0
  162. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_registry.py +0 -0
  163. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_reingest.py +0 -0
  164. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_render_markdown.py +0 -0
  165. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_sidecar.py +0 -0
  166. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_snapshot_settings.py +0 -0
  167. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_tokens.py +0 -0
  168. {backdraft-0.5.0 → backdraft-0.6.0}/tests/test_value_trace.py +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "backdraft",
3
3
  "displayName": "Backdraft",
4
- "version": "0.5.0",
4
+ "version": "0.6.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",
@@ -0,0 +1,36 @@
1
+ name: site
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ paths: ["site/**"]
6
+ workflow_dispatch:
7
+ permissions:
8
+ contents: read
9
+ pages: write
10
+ id-token: write
11
+ # One deployment at a time, and a publish in flight is never cancelled: a
12
+ # cancelled Pages deploy can leave the backend with a deployment that never
13
+ # completes, which is a stuck site rather than a lost run.
14
+ concurrency:
15
+ group: pages
16
+ cancel-in-progress: false
17
+ jobs:
18
+ # Build and deploy are separate so that re-running the failed job re-runs
19
+ # only the deploy. Bundled, a retry uploaded a second `github-pages`
20
+ # artifact and `deploy-pages` refused the run for finding two of them.
21
+ build:
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: actions/upload-pages-artifact@v3
26
+ with:
27
+ path: site/
28
+ deploy:
29
+ needs: build
30
+ runs-on: ubuntu-latest
31
+ environment:
32
+ name: github-pages
33
+ url: ${{ steps.deployment.outputs.page_url }}
34
+ steps:
35
+ - id: deployment
36
+ uses: actions/deploy-pages@v4
@@ -135,6 +135,11 @@ Self-contained HTML (plus the machine-readable sidecar): **quotes + receipts + e
135
135
  | 2026-07-29 | **The vision extractor ships by default (supersedes "[vlm] as extra").** `openai` + `pdf2image` moved into core dependencies; `[vlm]` remains as an empty compat alias. Rationale: the VLM path is the recommended path for real PDFs, and an extra made the recommended path opt-in. The consent boundary does not move — installing deps was never consent; the backdraft-scoped `BACKDRAFT_VLM_API_KEY` is, and ambient provider keys stay unread. There is no slim install: pip extras can only add, never subtract; a separate core distribution is possible later if anyone asks. poppler stays a runtime capability check. |
136
136
  | 2026-07-29 | **Renderer decomposed; Python is the formatting authority.** `render/html.py` (1,300 lines) became the `render/html/` package (fmt, text, components, assets, page), byte-identical at the seam. The deliberate `fmt_cell` (Python) / `fmtCell` (JS) duplication is now pinned by one shared vector table driving both tests, with the JS run under node when available. The JS was found diverging on exact rounding ties (`toFixed` rounds half-up; Python rounds half-to-even on the double's value) and was rewritten to round exactly — Python defines correct, JS matches it. The artifact emits minified CSS/JS and unindented markup; the `$legend` and sidecar JSON stay readable in view-source (spec constraint). Snapshot budget knobs `BACKDRAFT_SNAPSHOT_QUALITY`/`BACKDRAFT_SNAPSHOT_MAX_HEIGHT` tune page-image weight at ingest — display only, tokens never derive from pixels. |
137
137
  | 2026-07-30 | **Every PDF ingest captures page images, not just the VLM path's.** The text-layer route stored none, so the keyless path produced visually poorer artifacts for no principled reason — the pages render locally through poppler, no model calls, and the capability already existed as the `snapshot-pages` backfill. The capture is a step *after* extraction, owned by `cli.py` over `extract/snapshots.py`, deliberately **not** inside `pdf-text`: an extractor whose output varied with whether poppler happened to be installed would not be `deterministic = True`, and the same bytes must extract identically on every machine. Because snapshots only ever write `page_images` — a table no token, chunk, or receipt path reads — capture is safe to make best-effort: no poppler means ingest still exits 0, with a one-line note naming `snapshot-pages` as the backfill, rather than failing over a display concern. Tradeoff accepted: PDF ingest is now slower by a local render per page (one poppler call per page rather than one per document, to bound peak memory on long PDFs), and a library caller using `Registry.ingest` directly gets no snapshots — the policy lives at the CLI, which is the substrate that has a user to notify. |
138
+ | 2026-08-03 | **The card divides its own height; nothing inside it is pinned.** The receipt card is user-resizable, so it is a flex column: header, source selector, source line and tabs are fixed rows that never scroll away, and the evidence region takes whatever is left — dragging the card taller grows the evidence rather than the whitespace under it. The caps that survive are relative (the card at `82vh` of the window; the quote, drift diff and record at `40%` of the card), and a `5rem` floor keeps the evidence from vanishing at the card's minimum, with the citation scrolling instead. Resizing means *more*, not *smaller*: the page image keeps its scale and the pane scrolls over it, because a page shrunk to fit a short card is a page you cannot read, while the sheet window — which has no scale to lose — grows to fill, matching the full-sheet overlay it opens into. Both sheet views now stick their headers from one rule. Display only, as ever: no token, receipt or record moves, and the phone card keeps `resize:none` and the fixed caps it always had, since there is no drag there for the evidence to follow. |
139
+ | 2026-08-04 | **A theme is an override block, not a stylesheet.** The artifact's look is themeable, and a theme is a small TOML file naming values for the CSS custom properties the stylesheet already exposes — the sixteen colors and the three font roles — plus four bounded heading knobs (family, case, weight, tracking) that the theme module compiles into one rule over the document's own headings (`HEADING_SELECTOR`) that it owns. The built-in stylesheet is not edited and not replaced: the resolved theme is emitted as a block *after* it, so with nothing configured the artifact is byte-identical to an untheme'd build and theming is provably inert by default. A theme may not touch layout, structure, or any verification affordance, and never touches a token, receipt or record — `--rail-w` and `color-scheme` are deliberately outside the allowlist for that reason, and so is the artifact's refusal to follow the reader's system theme. Keys and values are both validated against declared allowlists, so `--colour` or `#GGG` fails by name before anything is written rather than producing a half-styled file; `url(...)` is rejected with the reason, since a font the file would have to fetch is exactly what the CSP forbids. Precedence is `--theme` > project `.backdraft/theme.toml` > XDG `~/.config/backdraft/theme.toml` > built-in, the user-wide file being what makes a preference stick across projects. The bundled `default.toml` restates today's values and is pinned by a test against the stylesheet's own `:root`, which makes it the audit as well as the reference sample. Tradeoff accepted: `--serif`/`--sans`/`--mono` name roles (body, UI, code) rather than classifications, so a sans-bodied theme sets `serif`. *(2026-08-04, follow-up: `--hl` was declared in `:root` and read by no rule, so it is deleted rather than left as a themeable key that paints nothing, and the `#fff` behind page images now tracks `--paper` — white there was coincidence, and every stored image is opaque RGB, so it was dead either way. The six constants that remain hardcoded — the card and rail shadows, the overlay scrim, the PDF tab accent, the `partial` verdict amber, sheet-header grey — were considered and deliberately left: each is near-neutral at any sane palette distance, and promoting them is a wider audit for churn rather than effect. Separately, `.doc h3` through `h6` had no rule at all — the markdown renderer emits every level and only two were styled, so a memo with `### ` fell to the browser's bold-Times default mid-page — and they are now a two-step scale that *brackets* the body rather than continuing down from `h2`, since the sheet sizes in `rem` while `body` sets 17px and a plain `1rem` heading lands under the text it heads. The theme's heading selector covers the whole scale for the same reason it exists: small-caps section heads above a body-face subsection read as a mistake, not a choice.)* |
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
+ | 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
+ | 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. |
138
143
 
139
144
  ## Open
140
145
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: backdraft
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -54,8 +54,9 @@ cd demo
54
54
 
55
55
  **Ingest** the sources. Every anchor is minted here, chunks for PDF pages,
56
56
  cells for spreadsheet sheets. Formats: PDF, XLSX/XLSM, XLS, CSV/TSV, DOCX,
57
- PPTX, images (png, jpeg, tiff, through the vision model), plain text and
58
- Markdown.
57
+ PPTX, HTML, images (png, jpeg, tiff, through the vision model), plain text and
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
60
 
60
61
  ```console
61
62
  $ backdraft init
@@ -65,7 +66,7 @@ documents: 0
65
66
 
66
67
  $ backdraft ingest sources/t12-summary.pdf sources/underwriting-model.xlsx
67
68
  t12-summary t12-summary.pdf pdf 3 pages
68
- underwriting-model underwriting-model.xlsx xlsx 2 pages
69
+ underwriting-model underwriting-model.xlsx xlsx 2 sheets
69
70
  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.
70
71
  ```
71
72
 
@@ -155,6 +156,33 @@ memo.backdraft.html
155
156
  That file is the whole deliverable: send it over email or Slack and the
156
157
  recipient gets the full experience by double-clicking it.
157
158
 
159
+ **Theming.** The artifact ships with a default look and two alternates,
160
+ `press` and `slate`:
161
+
162
+ ```console
163
+ $ backdraft render memo.md --theme slate
164
+ ```
165
+
166
+ A theme is a small TOML file naming colors, font stacks and heading treatment.
167
+ Drop one at `~/.config/backdraft/theme.toml` and every artifact you render, in
168
+ any project, honors it without a flag; `.backdraft/theme.toml` overrides that
169
+ for one project, and `--theme` overrides both. Themes are display only — they
170
+ never touch a token, a receipt or the record.
171
+
172
+ ```console
173
+ $ backdraft theme list
174
+ default
175
+ press
176
+ slate
177
+
178
+ in effect here: the built-in look
179
+ [Start your own: backdraft theme show default > /Users/you/.config/backdraft/theme.toml]
180
+ ```
181
+
182
+ `theme show` prints a validated file, so redirecting it writes a fully
183
+ commented starting point with every key and what it paints — and pointing it
184
+ at a file of your own checks that file without rendering anything.
185
+
158
186
  The full version of this, with every command and every output:
159
187
  [`demo/walkthrough.md`](demo/walkthrough.md). The artifact it produces is checked
160
188
  in at [`demo/memo.backdraft.html`](demo/memo.backdraft.html).
@@ -183,6 +211,38 @@ Failures are data throughout: `unresolved`, `not_shown`, `drifted`, `malformed`
183
211
  and `unmatched` are first-class records in the report and visible sections in the
184
212
  artifact. Nothing is ever warned about and dropped.
185
213
 
214
+ ## Web sources
215
+
216
+ `backdraft ingest https://example.com/q4-results` fetches the page once and
217
+ snapshots it exactly as a file: the bytes at fetch time are the document's
218
+ identity, and the URL and fetch timestamp ride along as provenance. So a page
219
+ that has changed since you cited it comes back as a new generation of the same
220
+ document and the old citations report `drifted` — the same machinery as an
221
+ edited PDF, which is the point of hashing the snapshot rather than trusting the
222
+ address.
223
+
224
+ ```console
225
+ $ backdraft ingest https://example.com/reports/q4-2025
226
+ q4-2025 q4-2025.html html 1 page
227
+
228
+ $ backdraft ls
229
+ q4-2025 q4-2025.html html 1 page https://example.com/reports/q4-2025
230
+ ```
231
+
232
+ The origin travels into the artifact too. A claim citing a fetched page shows
233
+ the URL as a link on its receipt, with the date the bytes were taken, and the
234
+ source list names the page rather than the staged filename. That pointer is the
235
+ half of citing a web page a frozen receipt cannot answer on its own: the
236
+ snippet says what the page said, the link is how a reader asks whether it still
237
+ says it.
238
+
239
+ What it does not do, stated rather than worked around: JavaScript-rendered
240
+ pages give you whatever the server sends to a plain GET, pages behind a login
241
+ are out of reach, and the extractor is a parse rather than a readability guess
242
+ — navigation and footers are part of the page, because a heuristic that changes
243
+ its mind between two versions of a site would move anchors. Responses are
244
+ capped at 32 MiB.
245
+
186
246
  ## What lives where
187
247
 
188
248
  A working directory stays clean: the authored document and its artifact are
@@ -29,8 +29,9 @@ cd demo
29
29
 
30
30
  **Ingest** the sources. Every anchor is minted here, chunks for PDF pages,
31
31
  cells for spreadsheet sheets. Formats: PDF, XLSX/XLSM, XLS, CSV/TSV, DOCX,
32
- PPTX, images (png, jpeg, tiff, through the vision model), plain text and
33
- Markdown.
32
+ PPTX, HTML, images (png, jpeg, tiff, through the vision model), plain text and
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
35
 
35
36
  ```console
36
37
  $ backdraft init
@@ -40,7 +41,7 @@ documents: 0
40
41
 
41
42
  $ backdraft ingest sources/t12-summary.pdf sources/underwriting-model.xlsx
42
43
  t12-summary t12-summary.pdf pdf 3 pages
43
- underwriting-model underwriting-model.xlsx xlsx 2 pages
44
+ underwriting-model underwriting-model.xlsx xlsx 2 sheets
44
45
  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.
45
46
  ```
46
47
 
@@ -130,6 +131,33 @@ memo.backdraft.html
130
131
  That file is the whole deliverable: send it over email or Slack and the
131
132
  recipient gets the full experience by double-clicking it.
132
133
 
134
+ **Theming.** The artifact ships with a default look and two alternates,
135
+ `press` and `slate`:
136
+
137
+ ```console
138
+ $ backdraft render memo.md --theme slate
139
+ ```
140
+
141
+ A theme is a small TOML file naming colors, font stacks and heading treatment.
142
+ Drop one at `~/.config/backdraft/theme.toml` and every artifact you render, in
143
+ any project, honors it without a flag; `.backdraft/theme.toml` overrides that
144
+ for one project, and `--theme` overrides both. Themes are display only — they
145
+ never touch a token, a receipt or the record.
146
+
147
+ ```console
148
+ $ backdraft theme list
149
+ default
150
+ press
151
+ slate
152
+
153
+ in effect here: the built-in look
154
+ [Start your own: backdraft theme show default > /Users/you/.config/backdraft/theme.toml]
155
+ ```
156
+
157
+ `theme show` prints a validated file, so redirecting it writes a fully
158
+ commented starting point with every key and what it paints — and pointing it
159
+ at a file of your own checks that file without rendering anything.
160
+
133
161
  The full version of this, with every command and every output:
134
162
  [`demo/walkthrough.md`](demo/walkthrough.md). The artifact it produces is checked
135
163
  in at [`demo/memo.backdraft.html`](demo/memo.backdraft.html).
@@ -158,6 +186,38 @@ Failures are data throughout: `unresolved`, `not_shown`, `drifted`, `malformed`
158
186
  and `unmatched` are first-class records in the report and visible sections in the
159
187
  artifact. Nothing is ever warned about and dropped.
160
188
 
189
+ ## Web sources
190
+
191
+ `backdraft ingest https://example.com/q4-results` fetches the page once and
192
+ snapshots it exactly as a file: the bytes at fetch time are the document's
193
+ identity, and the URL and fetch timestamp ride along as provenance. So a page
194
+ that has changed since you cited it comes back as a new generation of the same
195
+ document and the old citations report `drifted` — the same machinery as an
196
+ edited PDF, which is the point of hashing the snapshot rather than trusting the
197
+ address.
198
+
199
+ ```console
200
+ $ backdraft ingest https://example.com/reports/q4-2025
201
+ q4-2025 q4-2025.html html 1 page
202
+
203
+ $ backdraft ls
204
+ q4-2025 q4-2025.html html 1 page https://example.com/reports/q4-2025
205
+ ```
206
+
207
+ The origin travels into the artifact too. A claim citing a fetched page shows
208
+ the URL as a link on its receipt, with the date the bytes were taken, and the
209
+ source list names the page rather than the staged filename. That pointer is the
210
+ half of citing a web page a frozen receipt cannot answer on its own: the
211
+ snippet says what the page said, the link is how a reader asks whether it still
212
+ says it.
213
+
214
+ What it does not do, stated rather than worked around: JavaScript-rendered
215
+ pages give you whatever the server sends to a plain GET, pages behind a login
216
+ are out of reach, and the extractor is a parse rather than a readability guess
217
+ — navigation and footers are part of the page, because a heuristic that changes
218
+ its mind between two versions of a site would move anchors. Responses are
219
+ capped at 32 MiB.
220
+
161
221
  ## What lives where
162
222
 
163
223
  A working directory stays clean: the authored document and its artifact are
@@ -0,0 +1,258 @@
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. Bind's failure lines name the claim, not just the token
36
+
37
+ **Intent.** Exit 2 tells a calling agent that a citation did not resolve and
38
+ prints the token: `! unresolved: bd:t12-summary:p4.c1:1a2b`. It does not say
39
+ which sentence that token was on. `skills/backdraft/SKILL.md` then instructs
40
+ the agent to "tell the user which claim it belongs to" — the one fact the
41
+ report withheld — so the agent's next move is to grep its own document for a
42
+ token string. Meanwhile the `! unmatched:` line, three lines below, already
43
+ prints its claim text. The two line-items should carry the same weight.
44
+
45
+ **Shape.** `_print_report` in `bind/cli.py`, CLI output only — the record JSON
46
+ is a pinned format and already carries the claim/citation nesting, so nothing
47
+ in `spec/artifact.md` moves. `BindReport.claims` holds each claim's `text`,
48
+ `start` and `end` alongside its citations, so the mapping from a reported
49
+ citation back to its claim exists in memory; report the citation line as the
50
+ token, then the claim text truncated the way the `unmatched` line already
51
+ truncates it (80 chars), then the offset. Repeats matter here: the same token
52
+ cited on four claims currently prints four identical lines, which reads as four
53
+ problems — print one line per (token, claim) pair, which is what the offset
54
+ makes legible. Applies to every non-resolved status, `not_shown` and `drifted`
55
+ included, not only `unresolved`.
56
+
57
+ **Acceptance.** In `demo/`, `backdraft bind memo.md --session s-bridgeview
58
+ --check value-trace,overlap` prints the unresolved line with the replacement-
59
+ reserve claim text and its offset on it. A bind whose session was never read
60
+ into prints one line per distinct claim rather than one per citation
61
+ occurrence. Tests cover `unresolved`, `not_shown`, `drifted` and `malformed`.
62
+ Every doc that shows a bind report — `README.md`, `demo/walkthrough.md`,
63
+ `site/llms.txt`, `skills/backdraft/SKILL.md`,
64
+ `skills/backdraft-backfill/SKILL.md` — is updated to the real new output.
65
+
66
+ **Size.** One day.
67
+
68
+ ### 2. `--config` keys are declared, validated, and listed
69
+
70
+ **Intent.** `backdraft ingest x.pdf --config dpy=300` exits 0, ingests, and
71
+ does nothing about the typo. No output names the mistake, and nothing anywhere
72
+ — help, docs, `llms.txt` — lists what keys exist; today the only way to learn
73
+ `dpi`, `model`, `base_url`, `api_key`, `snapshot_quality` or
74
+ `snapshot_max_height` is to read `extract/vlm.py` and `extract/snapshots.py`.
75
+ This repo's rule is that failures are data and never warn-and-drop, and the
76
+ config boundary is where that rule does not hold.
77
+
78
+ **Shape.** Give the `Extractor` protocol in `extract/base.py` a
79
+ `config_keys: Mapping[str, str]` (key → one-line meaning), defaulting to empty
80
+ so no extractor is forced to declare. `vlm` declares its provider and retry
81
+ keys; the page-render keys (`dpi`, `snapshot_quality`, `snapshot_max_height`)
82
+ are declared once by `extract/snapshots.py`, which already owns them, and are
83
+ accepted for every PDF path since both rasterize. `ingest` validates the parsed
84
+ config against the selected extractor's declared set *after* selection, since
85
+ `auto` picks per file, and raises `UsageError` in the shape the unknown-name
86
+ errors already use: `unknown config key 'dpy' for pdf-text; known: dpi,
87
+ snapshot_max_height, snapshot_quality`. A key that is valid for one extractor
88
+ and not another must fail only where it does not apply.
89
+
90
+ **Acceptance.** The typo above exits 1 and names both the key and the valid
91
+ ones; `--config dpi=300` still works on both the `vlm` and the text-layer path;
92
+ `--config model=...` fails on `pdf-text` and passes on `vlm`. Tests for each
93
+ branch, including a declaration-free extractor rejecting any key. The docs
94
+ page's `ingest` row and `site/llms.txt` list the keys.
95
+
96
+ **Size.** Two days.
97
+
98
+ ### 3. `backdraft show <token>`: the inverse of minting
99
+
100
+ **Intent.** An agent handed a token — out of someone's artifact, a half-written
101
+ draft, a colleague's message — cannot ask what it says. The gate mints tokens
102
+ and `bind` resolves them, but only as part of binding a whole document, so
103
+ there is no way to answer "what is `bd:t12-summary:p1.c4:410d`?" without
104
+ reconstructing the slug and selector by hand and re-reading a whole page.
105
+ `skills/backdraft-artifact/SKILL.md` names exactly this gap when it says only
106
+ one check needs the outside world.
107
+
108
+ **Shape.** A new command in `gate/cli.py` — `show <token> [<token> ...]` —
109
+ because this is the gate showing source text and must mint what it shows,
110
+ exactly as `read` and `search` do; an anchor shown here is an anchor the writer
111
+ may cite, and `not_shown` stays true by construction. Parse with
112
+ `kernel.tokens`, resolve through the same path `bind` uses rather than a second
113
+ implementation of what a token means, and print the closed status set bind
114
+ reports: `resolved` (locator + verbatim snippet), `drifted` (the snippet then
115
+ and the snippet now), `unresolved`, `malformed`. Output follows the gate's
116
+ line-oriented shape and ends with the usual bracketed next-step hint.
117
+
118
+ **Acceptance.** In `demo/`, `backdraft show bd:t12-summary:p1.c1:c2e8` prints
119
+ the snippet, and a subsequent `bind` of a document citing it reports `resolved`
120
+ rather than `not_shown` — that is the test that proves it minted. A
121
+ well-formed token naming nothing exits 1 saying so; a malformed one names the
122
+ grammar. Several tokens in one invocation print in argument order. `README.md`,
123
+ the docs page's command table, `site/llms.txt` and
124
+ `skills/backdraft-artifact/SKILL.md` gain it.
125
+
126
+ **Size.** Two days.
127
+
128
+ ### 4. Ingest finishes the list, then reports what it could not read
129
+
130
+ **Intent.** `backdraft ingest a.md broken.pdf c.md` ingests `a.md`, fails on
131
+ `broken.pdf`, and never attempts `c.md`. The registry is left half-built, the
132
+ message names only the first failure, and nothing says which files landed and
133
+ which were never tried — so an agent ingesting a folder must re-run and infer
134
+ the state. This is the same warn-and-drop shape the page-snapshot capture
135
+ already avoids: it collects failures, names them, and lets the rest of the run
136
+ stand.
137
+
138
+ **Shape.** `ingest` in `cli.py`. Move the per-file work inside a try that
139
+ catches `BackdraftError` and records `(path, reason)` instead of propagating,
140
+ so the loop always reaches the end of the list. After the loop, print one line
141
+ per failure naming the file and the reason, grouped the way the snapshot note
142
+ already groups by reason, and exit 1 if anything failed — the exit code is
143
+ unchanged, only the coverage and the reporting improve. Re-ingest is idempotent
144
+ for deterministic extractors, so re-running after a fix costs nothing, and the
145
+ message should say that rather than leaving the agent to guess. Keep the guard
146
+ in `cli_context` as the only place `BackdraftError` becomes an exit code:
147
+ ingest catches per file and re-raises nothing, printing failures as data.
148
+
149
+ **Acceptance.** The three-file example above ingests `a.md` and `c.md`, prints
150
+ one failure line for `broken.pdf` naming why, and exits 1; `backdraft ls`
151
+ afterwards shows both good documents. A run where every file fails still exits
152
+ 1 and names each. A run where none fail is byte-identical to today's output.
153
+ Tests for all three.
154
+
155
+ **Size.** One day.
156
+
157
+ ### 5. The demo cites a web page, so the feature is visible
158
+
159
+ **Intent.** URL sources ship complete — `ingest` fetches and snapshots, the
160
+ registry keeps `url` and `fetched_at`, and as of 2026-08-06 the artifact links
161
+ back to the page with its fetch date. None of it appears at
162
+ `backdraft.dev/demo.html`, because both demo sources are local files
163
+ (`t12-summary.pdf`, `underwriting-model.xlsx`). The one artifact anybody
164
+ actually looks at does not show the capability, and the byte-identity test that
165
+ proved the change was safe is the same fact that keeps it invisible. A reader
166
+ evaluating backdraft cannot see that a web citation is a citation like any
167
+ other.
168
+
169
+ **Shape.** Add a third source to `demo/`: the Wikipedia article for the county
170
+ the property sits in, cited for market context — population, median household
171
+ income, the kind of external figure a screening memo genuinely leans on and
172
+ cannot get from a T12. Bridgeview Commons is fictional and says so in its own
173
+ first line, so give it a real county and let the memo name it; one new
174
+ paragraph under "The asset" or a short "Market" section, with one or two
175
+ claims. Cite Wikipedia's **permanent link** (`?oldid=<rev>`), not the bare
176
+ article URL: an article is edited constantly, a citation into the live URL
177
+ would report `drifted` within days, and a front-page demo that permanently
178
+ shows drift teaches that the tool is broken rather than that the source moved.
179
+ An immutable revision plus the rendered "as of" date is also what a careful
180
+ analyst cites. Write the tradeoff as a DESIGN row, naming the thing given up —
181
+ the demo no longer shows a live page changing under a citation, which is the
182
+ better story and belongs in a `drifted` walkthrough, not the shop window.
183
+
184
+ Practical notes for whoever builds this: `demo/.backdraft/` is gitignored, so
185
+ the fetch happens once on the implementer's machine and the snapshot then
186
+ travels in the local registry; `demo/generate_sources.py` builds the two
187
+ fixture files and is *not* where this goes, since the page is fetched rather
188
+ than generated. Re-bind and re-render (`bind memo.md --session s-bridgeview
189
+ --check value-trace,overlap`, then `render memo.md --to html`), and copy the
190
+ result over `site/demo.html` — the two are byte-identical by convention.
191
+ `demo/walkthrough.md` shows real CLI output and its `ls` and `bind` blocks both
192
+ move.
193
+
194
+ **Acceptance.** `backdraft.dev/demo.html` shows three sources, one of them a
195
+ clickable Wikipedia link with a fetch date, on both the resting rail and the
196
+ end-matter list; the claim citing it opens a card whose source line carries the
197
+ same link. `demo/walkthrough.md` matches what the commands now print. A re-bind
198
+ some weeks later still reports `resolved`, not `drifted` — that is the check
199
+ that the permanent link did its job.
200
+
201
+ **Size.** One day.
202
+
203
+ ## Parked
204
+
205
+ Deliberately not queued, each with the reason, so picking one up starts from
206
+ the objection rather than rediscovering it.
207
+
208
+ - **Exhibits view** — the evidence-first inversion. Risk: it grows the
209
+ artifact and pollutes a design whose strength is restraint. Needs a
210
+ deliberate design pass and a real audience before any code; not a
211
+ weekend-feature shape.
212
+ - **Excel region maps and range reads** — the research notes exist, but no
213
+ confidence the deterministic detection cascade lands well on the first
214
+ try. Revisit when large-sheet navigation hurts in a real corpus, with
215
+ test workbooks in hand.
216
+ - **`bd:calc` derivations** — reserved in the grammar and parsed as
217
+ malformed-on-purpose, which is enough for now. Full support complicates
218
+ binding and verification for one field-trial case.
219
+ - **Merged-cell and frozen-pane rendering** — ingest captures `merged` and
220
+ `frozen` in sheet meta and keeps them (cheap, and capture is the part you
221
+ cannot backfill). Frozen-pane rendering is cut; merged-cell rendering
222
+ waits for a real sheet that renders wrong without it.
223
+
224
+ ## Later
225
+
226
+ - **Living documents** — re-ingest, re-bind, and a drift-first report: "these
227
+ claims cite figures that changed; cited-then vs. now." The primitives
228
+ (generations, `drifted`, the word-diff) all exist; the missing piece is a
229
+ diff-shaped report and a demo.
230
+ - **Substrates beyond the CLI** — SDK middleware for pipelines (the exit-code
231
+ and `--to json` contracts are already shaped for it), and a client-side
232
+ drag-drop viewer page (no upload — the file never leaves the reader's
233
+ machine).
234
+ - **Email (.eml)** — arrives in diligence folders alongside everything else;
235
+ identity questions (headers vs. body, attachments as child sources) deserve
236
+ a decision row before code. The web-page half of this item shipped
237
+ 2026-08-05; `document_meta` is where an email's provenance would land.
238
+ - **Anthropic-API provider for the VLM extractor** — Cowork's sandbox
239
+ egress allowlist includes api.anthropic.com but not the OpenAI-compatible
240
+ providers, so an Anthropic-native option would let sandboxed sessions
241
+ ingest at full fidelity. Needs a client abstraction the vlm module
242
+ currently doesn't have; the documented workaround (ingest locally, the
243
+ registry travels with the folder) covers it meanwhile.
244
+ - **Distribution** — published on PyPI; the repo ships
245
+ `.claude-plugin/plugin.json` and `marketplace.json`, so it installs as a
246
+ Claude Code plugin and self-hosts its marketplace, and the AGENTS.md
247
+ snippet is published in the README and site docs. The community-directory
248
+ submission went in 2026-07-29 and is awaiting review — approval is what
249
+ surfaces the skills in Cowork's directory.
250
+
251
+ ## Someday
252
+
253
+ - **Hosted team features** — upload, access control, shared registries.
254
+ Deliberately parked: the product's trust story is the self-contained file,
255
+ and a hosted viewer inverts it. Revisit only as a distinct teams product.
256
+ - **Entail at scale** — the model-judge verifier is wired (`[entail]` extra)
257
+ but has never been field-calibrated; needs a corpus and a rubric before it
258
+ is recommended anywhere.
@@ -8,7 +8,7 @@ Design stance: this is a small codebase built as a base platform. A pure kernel
8
8
 
9
9
  | Concept | Is | Identity |
10
10
  |---|---|---|
11
- | **Document** | An ingested file | sha256 of bytes; human handle is a `slug` |
11
+ | **Document** | An ingested file, or a web page snapshotted at ingest | sha256 of bytes; human handle is a `slug`. A fetched page's origin URL is provenance metadata, never identity |
12
12
  | **Extraction** | A snapshot of a document's content produced by one extractor run | (document, extractor, version, config); generations are kept, one is `current` |
13
13
  | **Page** | Ordered unit within an extraction — a PDF page or a sheet | (extraction, number) |
14
14
  | **Anchor** | An addressable location in an extraction, carrying its **receipt**: verbatim snippet + snippet hash | locator within extraction; named by its token |
@@ -52,6 +52,7 @@ backdraft/
52
52
  pptx.py # python-pptx; one page per slide, text floor
53
53
  text.py # md/txt passthrough
54
54
  image.py # an image is a one-page document; VLM transcription
55
+ html.py # html/htm → one page of readable text; a parse, not a readability guess
55
56
  vlm.py # pdf→images→VLM per page (deps ship by default; key gates use)
56
57
  vlm_settings.py # provider/model/base-url resolution; stdlib-only
57
58
  gate/
@@ -72,9 +73,12 @@ backdraft/
72
73
  placement.py # locating each claim's span in the document render was handed
73
74
  footnotes.py # markdown projection
74
75
  sidecar.py # sidecar reader/writer over kernel/artifact.py's format
76
+ theme.py # the artifact's look: TOML in, one CSS override block out
77
+ themes/ # the bundled themes, as the same files a user would write
75
78
  _text.py # presentation helpers both renderers share (status prose, elision)
76
79
  cli.py # `render` — mounted by the top-level cli
77
- cli.py # typer entry; owns discovery, sessions, and the mounts
80
+ fetch.py # the whole network surface: one bounded GET over http(s), stdlib only
81
+ cli.py # typer entry; owns discovery, sessions, the network, and the mounts
78
82
  skills/
79
83
  backdraft/SKILL.md # front-walk
80
84
  backdraft-backfill/SKILL.md
@@ -134,10 +138,15 @@ CREATE TABLE documents (
134
138
  sha256 TEXT NOT NULL UNIQUE,
135
139
  path TEXT NOT NULL, -- as given at ingest; informational
136
140
  filename TEXT NOT NULL,
137
- media_type TEXT NOT NULL, -- 'pdf' | 'xlsx' | 'xls' | 'csv' | 'docx' | 'pptx' | 'image' | 'text'
141
+ media_type TEXT NOT NULL, -- 'pdf' | 'xlsx' | 'xls' | 'csv' | 'docx' | 'pptx' | 'image' | 'html' | 'text'
138
142
  created_at TEXT NOT NULL -- ISO-8601 UTC, everywhere
139
143
  );
140
144
 
145
+ CREATE TABLE document_meta (
146
+ document_id INTEGER PRIMARY KEY REFERENCES documents(id),
147
+ meta TEXT NOT NULL -- JSON provenance; a fetched page carries {url, fetched_at}. Never citation identity.
148
+ );
149
+
141
150
  CREATE TABLE extractions (
142
151
  id INTEGER PRIMARY KEY,
143
152
  document_id INTEGER NOT NULL REFERENCES documents(id),
@@ -226,7 +235,7 @@ class Extractor(Protocol):
226
235
  def extract(self, path: Path, config: dict) -> Iterator[ExtractedPage]: ...
227
236
  ```
228
237
 
229
- Registered in a plain dict; `ingest --extractor auto` picks the first `can_handle`, built-ins ordered `xlsx, xls, csv, docx, pptx, pdf-text, image, 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.
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.
230
239
 
231
240
  ## Gate (gate/)
232
241
 
@@ -273,17 +282,20 @@ All switches **default off** (`--check` opts in). Verdicts are recorded evidence
273
282
 
274
283
  - `render <authored.md> --to html` → single self-contained file: the bound doc, click/hover per claim → receipt card (quote, doc, locator, hashes, verdicts, drift diff if any), unresolved list rendered as a visible section, embedded JSON island with the full sidecar + `$format` string + legend (subtext pattern: the artifact teaches its own decoding). No external requests; no page images in v0.
275
284
  - `--to footnotes` → plain markdown projection. `--to json` → sidecar alone.
285
+ - `--theme <name|file>` restyles the html artifact — a TOML file of colors, font stacks and heading treatment, resolved by `render/theme.py` into one CSS block emitted *after* the stylesheet (so an unthemed render is byte-identical to one from before themes existed). Precedence: `--theme` > project `.backdraft/theme.toml` > XDG `~/.config/backdraft/theme.toml` > built-in. `backdraft theme list` names the bundled themes and which file is in effect; `theme show <name|file>` prints one, validated, so redirecting `show default` bootstraps a commented starting file and `show ./mine.toml` lints one. Display only: no token, receipt or record moves, and layout is outside the allowlist.
276
286
  - Artifact format string: `backdraft/artifact-v1` (prose spec in spec/artifact.md).
277
287
 
278
288
  ## CLI (cli.py — typer)
279
289
 
280
290
  ```
281
291
  backdraft init # create .backdraft/, print status
282
- backdraft ingest <files...> [--extractor auto] [--slug S] [--config k=v]
292
+ backdraft ingest <sources...> [--extractor auto] [--slug S] [--config k=v]
293
+ # a source is a path or an http(s) URL
283
294
  backdraft ls | backdraft read ... # gate, above
284
295
  backdraft search "<query>" [--in slug]
285
296
  backdraft bind <doc.md> [--session S] [--check ...] [--mode ...]
286
- backdraft render <doc.md> [--to html|footnotes|json] [-o out]
297
+ backdraft render <doc.md> [--to html|footnotes|json] [-o out] [--theme name|file]
298
+ backdraft theme [list|show <name|file>]
287
299
  backdraft export [--out registry.json]
288
300
  backdraft session [start|show]
289
301
  ```
@@ -323,10 +335,15 @@ class Registry:
323
335
 
324
336
  # write side (W1)
325
337
  def ingest(self, path: Path, *, extractor: str | None = None,
326
- slug: str | None = None, config: dict | None = None) -> Document: ...
338
+ slug: str | None = None, config: dict | None = None,
339
+ url: str | None = None, fetched_at: str | None = None) -> Document: ...
327
340
  # runs extractor → new extraction generation, pages, eager anchors, FTS rows.
328
341
  # Token carry-over per re-ingest semantics; hash-collision extension steps TOKEN_HASH_LENGTHS.
329
342
  # Sheet names sanitized to the sheetref charset here, at ingest.
343
+ # `url` marks `path` as a snapshot staged from the web: the document records the
344
+ # URL as its path, carries {url, fetched_at} as meta, and matches an earlier
345
+ # fetch by URL rather than by the temporary file. Identity stays the bytes.
346
+ # The registry never fetches; `fetch.py` does, and the CLI calls it.
330
347
 
331
348
  # read side (W1 implements; W2/W3 consume)
332
349
  def documents(self) -> list[Document]: ...
@@ -372,4 +389,4 @@ class Registry:
372
389
 
373
390
  ## Addendum B — CLI assembly
374
391
 
375
- 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. 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.
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.