backdraft 0.5.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. {backdraft-0.5.0 → backdraft-0.7.0}/.claude-plugin/plugin.json +1 -1
  2. backdraft-0.7.0/.github/workflows/site.yml +36 -0
  3. {backdraft-0.5.0 → backdraft-0.7.0}/DESIGN.md +8 -0
  4. {backdraft-0.5.0 → backdraft-0.7.0}/PKG-INFO +92 -7
  5. {backdraft-0.5.0 → backdraft-0.7.0}/README.md +90 -5
  6. backdraft-0.7.0/ROADMAP.md +316 -0
  7. {backdraft-0.5.0 → backdraft-0.7.0}/SPEC.md +30 -8
  8. {backdraft-0.5.0 → backdraft-0.7.0}/demo/memo.backdraft.html +38 -19
  9. {backdraft-0.5.0 → backdraft-0.7.0}/demo/walkthrough.md +71 -5
  10. {backdraft-0.5.0 → backdraft-0.7.0}/pyproject.toml +1 -1
  11. {backdraft-0.5.0 → backdraft-0.7.0}/site/demo.html +38 -19
  12. {backdraft-0.5.0 → backdraft-0.7.0}/site/docs.html +96 -7
  13. {backdraft-0.5.0 → backdraft-0.7.0}/site/index.html +7 -3
  14. {backdraft-0.5.0 → backdraft-0.7.0}/site/llms.txt +44 -10
  15. {backdraft-0.5.0 → backdraft-0.7.0}/skills/backdraft/SKILL.md +52 -12
  16. {backdraft-0.5.0 → backdraft-0.7.0}/skills/backdraft-artifact/SKILL.md +32 -3
  17. {backdraft-0.5.0 → backdraft-0.7.0}/skills/backdraft-backfill/SKILL.md +14 -0
  18. {backdraft-0.5.0 → backdraft-0.7.0}/spec/artifact.md +29 -8
  19. backdraft-0.7.0/src/backdraft/__init__.py +38 -0
  20. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/bind/binder.py +6 -15
  21. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/bind/cli.py +61 -3
  22. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/bind/evidence.py +27 -8
  23. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/cli.py +142 -37
  24. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/cli_context.py +8 -1
  25. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/extract/base.py +55 -3
  26. backdraft-0.7.0/src/backdraft/extract/html.py +331 -0
  27. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/extract/image.py +9 -2
  28. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/extract/pdf_text.py +6 -0
  29. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/extract/snapshots.py +37 -2
  30. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/extract/vlm.py +15 -2
  31. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/extract/vlm_settings.py +17 -0
  32. backdraft-0.7.0/src/backdraft/fetch.py +188 -0
  33. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/gate/__init__.py +7 -5
  34. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/gate/cli.py +35 -2
  35. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/gate/reader.py +166 -6
  36. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/kernel/artifact.py +4 -1
  37. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/kernel/model.py +12 -2
  38. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/registry/__init__.py +2 -0
  39. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/registry/schema.sql +6 -1
  40. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/registry/store.py +154 -35
  41. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/render/__init__.py +4 -0
  42. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/render/_text.py +18 -1
  43. backdraft-0.7.0/src/backdraft/render/cli.py +184 -0
  44. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/render/footnotes.py +27 -8
  45. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/render/html/__init__.py +5 -2
  46. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/render/html/assets.py +73 -16
  47. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/render/html/components.py +32 -7
  48. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/render/html/page.py +23 -29
  49. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/render/html/text.py +27 -0
  50. backdraft-0.7.0/src/backdraft/render/theme.py +372 -0
  51. backdraft-0.7.0/src/backdraft/render/themes/default.toml +44 -0
  52. backdraft-0.7.0/src/backdraft/render/themes/press.toml +28 -0
  53. backdraft-0.7.0/src/backdraft/render/themes/slate.toml +32 -0
  54. backdraft-0.7.0/tests/conftest.py +25 -0
  55. {backdraft-0.5.0 → backdraft-0.7.0}/tests/conftest_registry.py +11 -1
  56. backdraft-0.7.0/tests/css_util.py +66 -0
  57. {backdraft-0.5.0 → backdraft-0.7.0}/tests/gate/test_gate_cli.py +42 -0
  58. backdraft-0.7.0/tests/gate/test_reader_show.py +157 -0
  59. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/render/backfill.sidecar.json +1 -1
  60. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/render/demo.sidecar.json +1 -1
  61. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_bind_cli.py +100 -0
  62. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_bind_evidence.py +52 -0
  63. backdraft-0.7.0/tests/test_card_sizing.py +107 -0
  64. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_cli.py +144 -5
  65. backdraft-0.7.0/tests/test_docs.py +44 -0
  66. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_extract.py +90 -1
  67. backdraft-0.7.0/tests/test_extract_html.py +249 -0
  68. backdraft-0.7.0/tests/test_fetch.py +188 -0
  69. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_footnotes.py +48 -0
  70. backdraft-0.7.0/tests/test_gate_integration.py +211 -0
  71. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_html.py +129 -6
  72. backdraft-0.7.0/tests/test_ingest_url.py +354 -0
  73. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_reingest.py +56 -4
  74. backdraft-0.7.0/tests/test_render_cli.py +330 -0
  75. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_snapshots.py +15 -1
  76. backdraft-0.7.0/tests/test_theme.py +283 -0
  77. backdraft-0.7.0/tests/test_version.py +46 -0
  78. {backdraft-0.5.0 → backdraft-0.7.0}/uv.lock +1 -1
  79. backdraft-0.5.0/.github/workflows/site.yml +0 -26
  80. backdraft-0.5.0/ROADMAP.md +0 -177
  81. backdraft-0.5.0/src/backdraft/__init__.py +0 -13
  82. backdraft-0.5.0/src/backdraft/render/cli.py +0 -107
  83. backdraft-0.5.0/tests/conftest.py +0 -9
  84. backdraft-0.5.0/tests/test_gate_integration.py +0 -91
  85. backdraft-0.5.0/tests/test_render_cli.py +0 -161
  86. {backdraft-0.5.0 → backdraft-0.7.0}/.claude-plugin/marketplace.json +0 -0
  87. {backdraft-0.5.0 → backdraft-0.7.0}/.github/workflows/ci.yml +0 -0
  88. {backdraft-0.5.0 → backdraft-0.7.0}/.github/workflows/release.yml +0 -0
  89. {backdraft-0.5.0 → backdraft-0.7.0}/.gitignore +0 -0
  90. {backdraft-0.5.0 → backdraft-0.7.0}/.python-version +0 -0
  91. {backdraft-0.5.0 → backdraft-0.7.0}/LICENSE +0 -0
  92. {backdraft-0.5.0 → backdraft-0.7.0}/assets/backdraft-mark.svg +0 -0
  93. {backdraft-0.5.0 → backdraft-0.7.0}/demo/generate_sources.py +0 -0
  94. {backdraft-0.5.0 → backdraft-0.7.0}/demo/memo.md +0 -0
  95. {backdraft-0.5.0 → backdraft-0.7.0}/demo/sources/t12-summary.pdf +0 -0
  96. {backdraft-0.5.0 → backdraft-0.7.0}/demo/sources/underwriting-model.xlsx +0 -0
  97. {backdraft-0.5.0 → backdraft-0.7.0}/site/privacy.html +0 -0
  98. {backdraft-0.5.0 → backdraft-0.7.0}/spec/chunking.md +0 -0
  99. {backdraft-0.5.0 → backdraft-0.7.0}/spec/tokens.md +0 -0
  100. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/__main__.py +0 -0
  101. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/bind/__init__.py +0 -0
  102. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/bind/verify/__init__.py +0 -0
  103. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/bind/verify/base.py +0 -0
  104. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/bind/verify/entail.py +0 -0
  105. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/bind/verify/overlap.py +0 -0
  106. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/bind/verify/recompute.py +0 -0
  107. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/bind/verify/value_trace.py +0 -0
  108. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/credentials.py +0 -0
  109. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/extract/__init__.py +0 -0
  110. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/extract/csv.py +0 -0
  111. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/extract/docx.py +0 -0
  112. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/extract/pptx.py +0 -0
  113. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/extract/sheet.py +0 -0
  114. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/extract/text.py +0 -0
  115. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/extract/xls.py +0 -0
  116. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/extract/xlsx.py +0 -0
  117. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/gate/searcher.py +0 -0
  118. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/kernel/__init__.py +0 -0
  119. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/kernel/chunking.py +0 -0
  120. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/kernel/claims.py +0 -0
  121. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/kernel/errors.py +0 -0
  122. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/kernel/hashing.py +0 -0
  123. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/kernel/tokens.py +0 -0
  124. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/registry/ledger.py +0 -0
  125. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/render/html/fmt.py +0 -0
  126. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/render/html/fmt_vectors.py +0 -0
  127. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/render/markdown.py +0 -0
  128. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/render/placement.py +0 -0
  129. {backdraft-0.5.0 → backdraft-0.7.0}/src/backdraft/render/sidecar.py +0 -0
  130. {backdraft-0.5.0 → backdraft-0.7.0}/tests/conftest_render.py +0 -0
  131. {backdraft-0.5.0 → backdraft-0.7.0}/tests/fakes.py +0 -0
  132. {backdraft-0.5.0 → backdraft-0.7.0}/tests/gate/conftest.py +0 -0
  133. {backdraft-0.5.0 → backdraft-0.7.0}/tests/gate/fake_registry.py +0 -0
  134. {backdraft-0.5.0 → backdraft-0.7.0}/tests/gate/test_gate_ledger.py +0 -0
  135. {backdraft-0.5.0 → backdraft-0.7.0}/tests/gate/test_gate_reader.py +0 -0
  136. {backdraft-0.5.0 → backdraft-0.7.0}/tests/gate/test_gate_searcher.py +0 -0
  137. {backdraft-0.5.0 → backdraft-0.7.0}/tests/gate/test_reader_cells.py +0 -0
  138. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/bind/backfill.bound.md +0 -0
  139. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/bind/frontwalk.bound.md +0 -0
  140. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/chunking/long_tail.json +0 -0
  141. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/chunking/long_tail.txt +0 -0
  142. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/chunking/many_small.json +0 -0
  143. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/chunking/many_small.txt +0 -0
  144. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/chunking/mixed.json +0 -0
  145. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/chunking/mixed.txt +0 -0
  146. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/chunking/one_giant.json +0 -0
  147. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/chunking/one_giant.txt +0 -0
  148. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/chunking/short_page.json +0 -0
  149. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/chunking/short_page.txt +0 -0
  150. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/chunking/unsplittable.json +0 -0
  151. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/chunking/unsplittable.txt +0 -0
  152. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/render/backfill.footnotes.md +0 -0
  153. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden/render/demo.footnotes.md +0 -0
  154. {backdraft-0.5.0 → backdraft-0.7.0}/tests/golden_util.py +0 -0
  155. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_bind_binder.py +0 -0
  156. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_bind_verify.py +0 -0
  157. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_chunking.py +0 -0
  158. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_claims.py +0 -0
  159. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_credentials.py +0 -0
  160. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_fmt_parity.py +0 -0
  161. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_hashing.py +0 -0
  162. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_invariants.py +0 -0
  163. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_model.py +0 -0
  164. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_placement.py +0 -0
  165. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_records_location.py +0 -0
  166. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_registry.py +0 -0
  167. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_render_markdown.py +0 -0
  168. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_sidecar.py +0 -0
  169. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_snapshot_settings.py +0 -0
  170. {backdraft-0.5.0 → backdraft-0.7.0}/tests/test_tokens.py +0 -0
  171. {backdraft-0.5.0 → backdraft-0.7.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.7.0",
5
5
  "description": "Drop-in provenance for factual claims: one click from any claim to the evidence behind it. Ships the backdraft writing, backfill, and artifact-reading skills; the CLI installs separately (uv tool install backdraft).",
6
6
  "author": {
7
7
  "name": "Spencer Braun",
@@ -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,14 @@ 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. |
143
+ | 2026-08-11 | **An extractor declares the `--config` keys it reads, and an undeclared key is an error.** `--config dpy=300` used to exit 0, ingest, and change nothing: the config boundary was the one place this repo's "failures are data, never warn-and-drop" rule did not hold, and nothing — help, docs, `llms.txt` — said which keys existed, so learning them meant reading `extract/vlm.py` and `extract/snapshots.py`. The `Extractor` protocol gains an optional `config_keys: Mapping[str, str]` (key → one-line meaning) and `check_config` validates against it. Validation happens **after selection and inside `Registry.ingest`**, not in the CLI: `auto` picks per file, so only the chosen extractor can say whether `dpi` is a render setting or a typo, and putting it at the registry means every substrate is covered rather than only the one with a `--config` flag. It raises `ExtractionError` rather than the `UsageError` a CLI-side check would have used — `cli_context` imports the registry, so the registry cannot import `UsageError` back — and both are `BackdraftError`, so the guard maps either to exit 1 and the user sees the same line. Each key is declared once by the module that already owns its default and reads it: the page-render budget (`dpi`, `snapshot_quality`, `snapshot_max_height`) in `extract/snapshots.py`, the provider surface (`api_key`, `base_url`, `model`, `timeout`, `retries`) in `extract/vlm_settings.py`, and the extractors compose those by reference. `pdf-text` declares the page-render keys despite reading none of them itself — the snapshot capture that runs beside it at ingest does, and a user passing `dpi` to a text-layer ingest means the images; declaring by *what one flag on one ingest affects* beats declaring by which function happens to call `config.get`. Tradeoff accepted: declaring nothing means accepting nothing, so an out-of-tree extractor that reads config without declaring it starts rejecting keys it used to receive. That is the intended direction — a silently ignored key is the failure being fixed — and the fix is one attribute. The help text that lists the keys is pinned by a test against the declarations, so a new key cannot be added in code and left undocumented. |
144
+ | 2026-08-12 | **`show` is a gate verb, not a lookup tool.** `backdraft show <token>` answers what a token says — the inverse of minting, for a token that arrived out of an artifact, a draft, or somebody's message, which nothing in the tool could answer before without rebuilding the slug and selector by hand and re-reading a whole page. It therefore *mints what it shows*, exactly as `read` and `search` do: an agent that has seen a snippet through the gate has genuinely read it, so recording it is honest rather than a loophole, and `not_shown` stays true by construction — a status `show` can never produce. That makes a token laundering into a citable one by being looked at, which is the intended behavior and the reason this is in `gate/` and not somewhere neutral. Resolution is bind's path rather than a second reading of what a token means: `kernel.claims.parse_citation` decides `malformed` (so the reserved `bd:calc(...)` form lands identically in both) and `Registry.resolve` decides the rest, which is what makes a status printed here the status bind would print. Two smaller calls. `drifted` prints both snippets *and* mints the anchor standing at the locator now, since that anchor's token is the one worth citing and printing it unrecorded would be the under-minting the gate must never do. And drift does **not** fail the exit code: exit 1 covers `unresolved` and `malformed`, the two statuses where there was nothing to show, because whether a changed source still supports a sentence is bind's question about a document, not a lookup's about a token. Tradeoff accepted: auditing someone else's artifact against a registry now writes ledger rows into the auditor's session, so `skills/backdraft-artifact/SKILL.md` says so and keeps that check reported separately from the artifact's self-checks. |
145
+ | 2026-08-13 | **`ingest` finishes its list; an unreadable source is data, not the end of the run.** `backdraft ingest a.md broken.pdf c.md` used to ingest `a.md`, raise on `broken.pdf`, and never attempt `c.md` — leaving a half-built registry, a message naming only the first failure, and no way to tell which sources landed from which were never tried, so ingesting a folder meant re-running and inferring the state. The loop now catches `BackdraftError` per source and records `(source, reason)`, so every source is attempted, and prints one `!` line per failure under a count (`2 of 3 sources ingested; 1 failed`) whose two numbers add to the whole list — which is precisely the fact the old behavior could not state. This is principle 4 ("failures are data, never warn-and-drop") applied at the boundary it had not reached, and the same shape the page-snapshot note already uses one level down. Grouping differs deliberately: the snapshot note groups by *reason* because one missing poppler explains every document at once, while this lists by *source*, because the source is what the caller has to go fix. The exit code is unchanged at 1 and `guard` remains the only place a `BackdraftError` becomes one — nothing caught per source is re-raised, and the report exits through `fail` like any other error line, on stderr, so a clean run's stdout is byte-identical to before and that is pinned by a test. Tradeoff accepted: a run that fails partway now leaves *more* in the registry than it used to, so "the command failed" no longer implies "nothing happened". The closing line of the report answers that directly — re-running the same list is safe, because a source already ingested with unchanged bytes, extractor and config is a no-op — which is cheaper for a caller than reconstructing the failed half by diffing `ls` against its own arguments. |
138
146
 
139
147
  ## Open
140
148
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: backdraft
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Drop-in provenance for factual claims: one click from any claim to the evidence behind it.
5
5
  Project-URL: Homepage, https://backdraft.dev
6
6
  Project-URL: Documentation, https://backdraft.dev/docs.html
@@ -54,8 +54,11 @@ 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. A source that
60
+ cannot be read does not end the run: the rest of the list is ingested anyway,
61
+ and the command exits 1 naming each source that failed and why.
59
62
 
60
63
  ```console
61
64
  $ backdraft init
@@ -65,7 +68,7 @@ documents: 0
65
68
 
66
69
  $ backdraft ingest sources/t12-summary.pdf sources/underwriting-model.xlsx
67
70
  t12-summary t12-summary.pdf pdf 3 pages
68
- underwriting-model underwriting-model.xlsx xlsx 2 pages
71
+ underwriting-model underwriting-model.xlsx xlsx 2 sheets
69
72
  note: extracted with pdf-text (the embedded text layer). Glossy or scanned PDFs extract better through a vision model: set BACKDRAFT_VLM_API_KEY in .backdraft/env.
70
73
  ```
71
74
 
@@ -116,6 +119,25 @@ $ backdraft cell underwriting-model "assumptions!B10"
116
119
  [bd:underwriting-model:assumptions!B10:964a] 24850000
117
120
  ```
118
121
 
122
+ And a token you were handed — out of an artifact, a draft, someone else's
123
+ message — runs backwards to what it actually says:
124
+
125
+ ```console
126
+ $ backdraft show bd:t12-summary:p1.c1:c2e8
127
+ [bd:t12-summary:p1.c1:c2e8] resolved t12-summary p1.c1
128
+ Bridgeview Commons — Trailing Twelve Month Summary
129
+
130
+ Property: Bridgeview Commons, 4400 Halsted Avenue, Columbus, OH 43214. 128 units across four three-story
131
+ garden buildings on 6.2 acres, built in 1998 and substantially renovated between 2019 and 2021. …
132
+
133
+ [Read the page: backdraft read t12-summary p1]
134
+ ```
135
+
136
+ `show` is the gate too, so what it shows is minted and citable. A token whose
137
+ source has changed since prints both snippets, the one that was cited and the
138
+ one standing there now; a token that names nothing exits 1 and says whether the
139
+ slug or the locator is the wrong half.
140
+
119
141
  **Cite** by writing the token as the href of a markdown link on the claim span.
120
142
  Multiple citations are `;`-separated in one href.
121
143
 
@@ -133,7 +155,7 @@ bound 15 claim(s), 16 citation(s) [frontwalk]
133
155
  unresolved: 1
134
156
  overlap: pass 11, skip 4
135
157
  value-trace: pass 15
136
- ! unresolved: bd:t12-summary:p4.c1:1a2b
158
+ ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @2900
137
159
  wrote .backdraft/records/memo.backdraft.json
138
160
 
139
161
  $ echo $?
@@ -155,6 +177,33 @@ memo.backdraft.html
155
177
  That file is the whole deliverable: send it over email or Slack and the
156
178
  recipient gets the full experience by double-clicking it.
157
179
 
180
+ **Theming.** The artifact ships with a default look and two alternates,
181
+ `press` and `slate`:
182
+
183
+ ```console
184
+ $ backdraft render memo.md --theme slate
185
+ ```
186
+
187
+ A theme is a small TOML file naming colors, font stacks and heading treatment.
188
+ Drop one at `~/.config/backdraft/theme.toml` and every artifact you render, in
189
+ any project, honors it without a flag; `.backdraft/theme.toml` overrides that
190
+ for one project, and `--theme` overrides both. Themes are display only — they
191
+ never touch a token, a receipt or the record.
192
+
193
+ ```console
194
+ $ backdraft theme list
195
+ default
196
+ press
197
+ slate
198
+
199
+ in effect here: the built-in look
200
+ [Start your own: backdraft theme show default > /Users/you/.config/backdraft/theme.toml]
201
+ ```
202
+
203
+ `theme show` prints a validated file, so redirecting it writes a fully
204
+ commented starting point with every key and what it paints — and pointing it
205
+ at a file of your own checks that file without rendering anything.
206
+
158
207
  The full version of this, with every command and every output:
159
208
  [`demo/walkthrough.md`](demo/walkthrough.md). The artifact it produces is checked
160
209
  in at [`demo/memo.backdraft.html`](demo/memo.backdraft.html).
@@ -183,6 +232,38 @@ Failures are data throughout: `unresolved`, `not_shown`, `drifted`, `malformed`
183
232
  and `unmatched` are first-class records in the report and visible sections in the
184
233
  artifact. Nothing is ever warned about and dropped.
185
234
 
235
+ ## Web sources
236
+
237
+ `backdraft ingest https://example.com/q4-results` fetches the page once and
238
+ snapshots it exactly as a file: the bytes at fetch time are the document's
239
+ identity, and the URL and fetch timestamp ride along as provenance. So a page
240
+ that has changed since you cited it comes back as a new generation of the same
241
+ document and the old citations report `drifted` — the same machinery as an
242
+ edited PDF, which is the point of hashing the snapshot rather than trusting the
243
+ address.
244
+
245
+ ```console
246
+ $ backdraft ingest https://example.com/reports/q4-2025
247
+ q4-2025 q4-2025.html html 1 page
248
+
249
+ $ backdraft ls
250
+ q4-2025 q4-2025.html html 1 page https://example.com/reports/q4-2025
251
+ ```
252
+
253
+ 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.
259
+
260
+ What it does not do, stated rather than worked around: JavaScript-rendered
261
+ pages give you whatever the server sends to a plain GET, pages behind a login
262
+ are out of reach, and the extractor is a parse rather than a readability guess
263
+ — navigation and footers are part of the page, because a heuristic that changes
264
+ its mind between two versions of a site would move anchors. Responses are
265
+ capped at 32 MiB.
266
+
186
267
  ## What lives where
187
268
 
188
269
  A working directory stays clean: the authored document and its artifact are
@@ -209,7 +290,11 @@ explicit, backdraft-scoped consent: `BACKDRAFT_VLM_API_KEY` (env or
209
290
  `OPENAI_API_KEY`/`OPENROUTER_API_KEY` are deliberately never read; a generic
210
291
  key in the environment is not consent to send documents to its provider. The
211
292
  default provider is OpenRouter running Gemini 3.1 Flash Lite; direct OpenAI is
212
- base_url + model, set explicitly. Without a scoped key, `auto` falls back to
293
+ base_url + model, set explicitly (`--config base_url=...` and `--config
294
+ model=...`, one key per flag). Every `--config` key is declared by the extractor
295
+ that reads it and checked against the one `ingest` chose, so a misspelled key
296
+ fails and names the keys that apply rather than being silently ignored.
297
+ Without a scoped key, `auto` falls back to
213
298
  the keyless text layer and says so — still storing each page's image, which it
214
299
  renders locally. PDF page rendering needs poppler, installed separately; without
215
300
  it ingest succeeds without the images and names `snapshot-pages` as the backfill.
@@ -29,8 +29,11 @@ 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. A source that
35
+ cannot be read does not end the run: the rest of the list is ingested anyway,
36
+ and the command exits 1 naming each source that failed and why.
34
37
 
35
38
  ```console
36
39
  $ backdraft init
@@ -40,7 +43,7 @@ documents: 0
40
43
 
41
44
  $ backdraft ingest sources/t12-summary.pdf sources/underwriting-model.xlsx
42
45
  t12-summary t12-summary.pdf pdf 3 pages
43
- underwriting-model underwriting-model.xlsx xlsx 2 pages
46
+ underwriting-model underwriting-model.xlsx xlsx 2 sheets
44
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.
45
48
  ```
46
49
 
@@ -91,6 +94,25 @@ $ backdraft cell underwriting-model "assumptions!B10"
91
94
  [bd:underwriting-model:assumptions!B10:964a] 24850000
92
95
  ```
93
96
 
97
+ And a token you were handed — out of an artifact, a draft, someone else's
98
+ message — runs backwards to what it actually says:
99
+
100
+ ```console
101
+ $ backdraft show bd:t12-summary:p1.c1:c2e8
102
+ [bd:t12-summary:p1.c1:c2e8] resolved t12-summary p1.c1
103
+ Bridgeview Commons — Trailing Twelve Month Summary
104
+
105
+ Property: Bridgeview Commons, 4400 Halsted Avenue, Columbus, OH 43214. 128 units across four three-story
106
+ garden buildings on 6.2 acres, built in 1998 and substantially renovated between 2019 and 2021. …
107
+
108
+ [Read the page: backdraft read t12-summary p1]
109
+ ```
110
+
111
+ `show` is the gate too, so what it shows is minted and citable. A token whose
112
+ source has changed since prints both snippets, the one that was cited and the
113
+ one standing there now; a token that names nothing exits 1 and says whether the
114
+ slug or the locator is the wrong half.
115
+
94
116
  **Cite** by writing the token as the href of a markdown link on the claim span.
95
117
  Multiple citations are `;`-separated in one href.
96
118
 
@@ -108,7 +130,7 @@ bound 15 claim(s), 16 citation(s) [frontwalk]
108
130
  unresolved: 1
109
131
  overlap: pass 11, skip 4
110
132
  value-trace: pass 15
111
- ! unresolved: bd:t12-summary:p4.c1:1a2b
133
+ ! unresolved: bd:t12-summary:p4.c1:1a2b — replacement reserve of $250 per unit per year @2900
112
134
  wrote .backdraft/records/memo.backdraft.json
113
135
 
114
136
  $ echo $?
@@ -130,6 +152,33 @@ memo.backdraft.html
130
152
  That file is the whole deliverable: send it over email or Slack and the
131
153
  recipient gets the full experience by double-clicking it.
132
154
 
155
+ **Theming.** The artifact ships with a default look and two alternates,
156
+ `press` and `slate`:
157
+
158
+ ```console
159
+ $ backdraft render memo.md --theme slate
160
+ ```
161
+
162
+ A theme is a small TOML file naming colors, font stacks and heading treatment.
163
+ Drop one at `~/.config/backdraft/theme.toml` and every artifact you render, in
164
+ any project, honors it without a flag; `.backdraft/theme.toml` overrides that
165
+ for one project, and `--theme` overrides both. Themes are display only — they
166
+ never touch a token, a receipt or the record.
167
+
168
+ ```console
169
+ $ backdraft theme list
170
+ default
171
+ press
172
+ slate
173
+
174
+ in effect here: the built-in look
175
+ [Start your own: backdraft theme show default > /Users/you/.config/backdraft/theme.toml]
176
+ ```
177
+
178
+ `theme show` prints a validated file, so redirecting it writes a fully
179
+ commented starting point with every key and what it paints — and pointing it
180
+ at a file of your own checks that file without rendering anything.
181
+
133
182
  The full version of this, with every command and every output:
134
183
  [`demo/walkthrough.md`](demo/walkthrough.md). The artifact it produces is checked
135
184
  in at [`demo/memo.backdraft.html`](demo/memo.backdraft.html).
@@ -158,6 +207,38 @@ Failures are data throughout: `unresolved`, `not_shown`, `drifted`, `malformed`
158
207
  and `unmatched` are first-class records in the report and visible sections in the
159
208
  artifact. Nothing is ever warned about and dropped.
160
209
 
210
+ ## Web sources
211
+
212
+ `backdraft ingest https://example.com/q4-results` fetches the page once and
213
+ snapshots it exactly as a file: the bytes at fetch time are the document's
214
+ identity, and the URL and fetch timestamp ride along as provenance. So a page
215
+ that has changed since you cited it comes back as a new generation of the same
216
+ document and the old citations report `drifted` — the same machinery as an
217
+ edited PDF, which is the point of hashing the snapshot rather than trusting the
218
+ address.
219
+
220
+ ```console
221
+ $ backdraft ingest https://example.com/reports/q4-2025
222
+ q4-2025 q4-2025.html html 1 page
223
+
224
+ $ backdraft ls
225
+ q4-2025 q4-2025.html html 1 page https://example.com/reports/q4-2025
226
+ ```
227
+
228
+ 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.
234
+
235
+ What it does not do, stated rather than worked around: JavaScript-rendered
236
+ pages give you whatever the server sends to a plain GET, pages behind a login
237
+ are out of reach, and the extractor is a parse rather than a readability guess
238
+ — navigation and footers are part of the page, because a heuristic that changes
239
+ its mind between two versions of a site would move anchors. Responses are
240
+ capped at 32 MiB.
241
+
161
242
  ## What lives where
162
243
 
163
244
  A working directory stays clean: the authored document and its artifact are
@@ -184,7 +265,11 @@ explicit, backdraft-scoped consent: `BACKDRAFT_VLM_API_KEY` (env or
184
265
  `OPENAI_API_KEY`/`OPENROUTER_API_KEY` are deliberately never read; a generic
185
266
  key in the environment is not consent to send documents to its provider. The
186
267
  default provider is OpenRouter running Gemini 3.1 Flash Lite; direct OpenAI is
187
- base_url + model, set explicitly. Without a scoped key, `auto` falls back to
268
+ base_url + model, set explicitly (`--config base_url=...` and `--config
269
+ model=...`, one key per flag). Every `--config` key is declared by the extractor
270
+ that reads it and checked against the one `ingest` chose, so a misspelled key
271
+ fails and names the keys that apply rather than being silently ignored.
272
+ Without a scoped key, `auto` falls back to
188
273
  the keyless text layer and says so — still storing each page's image, which it
189
274
  renders locally. PDF page rendering needs poppler, installed separately; without
190
275
  it ingest succeeds without the images and names `snapshot-pages` as the backfill.