diff --git a/README.md b/README.md
index 0f88113..652a780 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,16 @@
# evidence-source
-Headless document ingest for the citation-evidence ecosystem. Turns raw PDF
-bytes into engine-owned evidence contracts — a `Document` (media type,
-SHA-256 fingerprint, optional title/uri/metadata) and a
-`DocumentRepresentation` (`pdf-text`: canonical text, page map, gap-free
-offset map). Ingest is pure over bytes: no persistence, no viewer state, no
-React.
+Headless document ingest for the citation-evidence ecosystem. Turns raw PDF,
+HTML, and Markdown bytes into engine-owned evidence contracts — a `Document`
+(media type, SHA-256 fingerprint, optional title/uri/metadata) and a
+`DocumentRepresentation` (canonical text, page/offset maps where applicable).
+Ingest is pure over bytes: no persistence, no viewer state, no React.
## Status
-**Implemented: the PDF slice.** As of ESRC-WP-0001 this repo hosts the
-extracted PDF ingest core that previously lived in `citation-evidence/src/source/`.
-HTML/Markdown representations, richer metadata enrichment, and citation
-recovery are deferred to follow-on workplans (see `workplans/`).
+**Implemented:** PDF ingest (ESRC-WP-0001), HTML/Markdown ingest
+(ESRC-WP-0002), PDF metadata enrichment (ESRC-WP-0003), and citation recovery
+primitives (ESRC-WP-0004). See `workplans/` for history.
## Install
@@ -26,13 +24,17 @@ pnpm install # resolves @citation-evidence/engine via link:../citation-engine
## Usage
```ts
-import { ingestPdf } from "@citation-evidence/evidence-source";
+import {
+ ingestPdf,
+ ingestHtml,
+ ingestMarkdown,
+} from "@citation-evidence/evidence-source";
-const { document, representation } = await ingestPdf(pdfBytes, {
- filename: "contract.pdf",
-});
-// document.fingerprint -> SHA-256 hex
-// representation.canonicalText / pageMap / offsetMap
+const pdf = await ingestPdf(pdfBytes, { filename: "contract.pdf" });
+const html = await ingestHtml(htmlBytes, { filename: "brief.html" });
+const md = await ingestMarkdown("# Title\n\nBody text.", { filename: "notes.md" });
+// document.fingerprint -> SHA-256 hex
+// representation.canonicalText / pageMap / offsetMap (format-dependent)
```
Browser upload helpers (in-memory `blob:` byte store + `ingestPdfFromFile`)
@@ -61,12 +63,14 @@ Point `EVIDENCE_SOURCE_FIXTURE_DIR` at the PDF corpus when the sibling
## Architecture
-- `src/pdf/` — headless core: `ingest`, `extract`, `fingerprint`. Runtime-agnostic.
+- `src/pdf/` — PDF ingest, extraction, fingerprinting, metadata enrichment.
+- `src/html/`, `src/markdown/` — reflowable-format ingest (ADR-0003).
+- `src/recovery/` — re-ingest/reconcile, local quote search, discovery hooks (ADR-0004).
- `src/browser/` — browser upload surface: `byte-store`, `upload`. Separated by
an ESLint boundary so the core never imports it.
- Domain contracts come from `@citation-evidence/engine/shared`; none are
copied locally.
-- Boundary and fixture decisions: `docs/ADR-0001`, `docs/ADR-0002`.
+- Boundary and fixture decisions: `docs/ADR-0001` through `docs/ADR-0004`.
`viewer-url` resolution stays in the consuming app (`citation-evidence`), which
imports this package's ingest core through a thin façade.
diff --git a/SCOPE.md b/SCOPE.md
index ca66d5b..b886d7b 100644
--- a/SCOPE.md
+++ b/SCOPE.md
@@ -8,8 +8,8 @@
## One-liner
-Headless PDF ingest that turns document bytes into engine-owned evidence
-contracts (fingerprint, canonical text, page/offset maps).
+Headless document ingest that turns PDF, HTML, and Markdown bytes into
+engine-owned evidence contracts (fingerprint, canonical text, page/offset maps).
---
@@ -18,25 +18,30 @@ contracts (fingerprint, canonical text, page/offset maps).
The rest of the citation-evidence ecosystem (anchoring, evidence linking,
binders) needs a stable, runtime-agnostic way to go from *raw document bytes*
to a `Document` + `DocumentRepresentation`. This repo owns that transformation
-for PDFs — pure over bytes, no persistence, no viewer, no UI framework.
+for PDF, HTML, and Markdown — pure over bytes, no persistence, no viewer,
+no UI framework.
---
## In Scope
- PDF byte ingest → `{ document, representation }` (`ingestPdf`)
+- HTML byte ingest → `{ document, representation }` (`ingestHtml`)
+- Markdown byte ingest → `{ document, representation }` (`ingestMarkdown`)
- PDF text extraction → canonical text + page map + gap-free offset map (`extractPdf`)
+- PDF intrinsic metadata extraction with caller-wins merge (`extractPdfMetadata`)
- SHA-256 byte fingerprinting (`fingerprintBytes`)
+- Citation recovery primitives: re-ingest/reconcile, local quote search, discovery hooks
- Browser upload helpers behind a separate entry point (`createPdfByteStore`, `ingestPdfFromFile`)
---
## Out of Scope
-- HTML / Markdown representations (deferred — see `workplans/`)
- Persisting documents or representations (caller's job)
- Viewer URL resolution and blob-vs-fixture policy (stays in `citation-evidence`)
-- Citation recovery / external source discovery (deferred)
+- Stale selector detection and anchor resolution (stays in `evidence-anchor`)
+- Network-backed external source discovery implementations (host registers hooks)
- Defining domain contracts — those are owned by `citation-engine`
---
@@ -51,7 +56,7 @@ for PDFs — pure over bytes, no persistence, no viewer, no UI framework.
## Not Relevant When
-- You need HTML/Markdown ingest (not yet implemented)
+- You need formats beyond PDF/HTML/Markdown (not yet implemented)
- You need viewer/session/UI behavior (see `citation-evidence`)
- You are changing the `Document`/`DocumentRepresentation` contract (see `citation-engine`)
@@ -60,7 +65,7 @@ for PDFs — pure over bytes, no persistence, no viewer, no UI framework.
## Current State
- Status: active
-- Implementation: partial (PDF slice implemented; HTML/MD and recovery deferred)
+- Implementation: partial (PDF/HTML/MD ingest, metadata enrichment, recovery primitives)
- Stability: evolving
- Usage: internal (consumed by `citation-evidence` across the repo boundary)
@@ -92,7 +97,7 @@ for PDFs — pure over bytes, no persistence, no viewer, no UI framework.
## Getting Oriented
- Start with: `README.md`, then `docs/ADR-0001-extraction-boundary.md`
-- Key files / directories: `src/pdf/` (headless core), `src/browser/` (upload helpers)
+- Key files / directories: `src/pdf/`, `src/html/`, `src/markdown/`, `src/recovery/`, `src/browser/`
- Entry points: `src/index.ts` (headless), `src/browser/index.ts` (browser)
---
diff --git a/docs/ADR-0003-html-markdown-representation.md b/docs/ADR-0003-html-markdown-representation.md
new file mode 100644
index 0000000..f06214b
--- /dev/null
+++ b/docs/ADR-0003-html-markdown-representation.md
@@ -0,0 +1,58 @@
+# ADR-0003 — HTML and Markdown representation contract
+
+- **Status:** accepted
+- **Date:** 2026-07-09
+- **Workplan:** ESRC-WP-0002 (T01)
+
+## Context
+
+ESRC-WP-0001 established the PDF ingest boundary. ESRC-WP-0002 extends ingest to
+HTML and Markdown while reusing engine-owned contracts from
+`@citation-evidence/engine/shared`.
+
+`RepresentationType` already reserves `html-dom` and `markdown-rendered` in
+`citation-engine`; no engine schema change is required for this slice.
+
+## Decision
+
+### Representation types
+
+| Source format | `mediaType` | `representationType` |
+| ------------- | ------------------ | ---------------------- |
+| HTML | `text/html` | `html-dom` |
+| Markdown | `text/markdown` | `markdown-rendered` |
+
+### Pageless offset semantics
+
+Reflowable formats have no fixed pages:
+
+- `pageMap` is **omitted** (undefined).
+- `offsetMap` is a **single synthetic range** on page 1 covering
+ `[0, canonicalText.length)` with no gaps. This keeps
+ `TextPositionSelector` usable without inventing physical page geometry.
+- `structureMap` remains `never` in the engine contract and is not populated.
+
+### Canonical text
+
+Both pipelines decode source bytes as UTF-8, derive plain text from the format,
+then apply `normalize()` from `@citation-evidence/engine/shared`. HTML strips
+active content (`script`, `style`, `iframe`, `object`, `embed`, `noscript`)
+before text extraction.
+
+### Content hash
+
+`contentHash` is the SHA-256 fingerprint of the **source bytes** (same rule as
+PDF ingest), not a hash of canonical text.
+
+### Fixtures
+
+HTML/Markdown contract tests use small inline fixtures owned by this repo. They
+contain no PII and do not duplicate the upstream PDF corpus (ADR-0002).
+
+## Consequences
+
+- `ingestHtml` and `ingestMarkdown` mirror the PDF `{ document, representation }`
+ shape.
+- Anchoring layers may add DOM/structural selectors later; this slice delivers
+ canonical text and position offsets only.
+- Sanitization is extraction-time stripping, not a sandboxed renderer.
\ No newline at end of file
diff --git a/docs/ADR-0004-citation-recovery-boundary.md b/docs/ADR-0004-citation-recovery-boundary.md
new file mode 100644
index 0000000..7bbb980
--- /dev/null
+++ b/docs/ADR-0004-citation-recovery-boundary.md
@@ -0,0 +1,54 @@
+# ADR-0004 — Citation recovery boundary
+
+- **Status:** accepted
+- **Date:** 2026-07-09
+- **Workplan:** ESRC-WP-0004 (T01)
+
+## Context
+
+`evidence-source` owns ingestion and representation generation. Citation
+recovery spans stale-selector detection, local quote search, external source
+discovery, and human confirmation — but selector resolution algorithms live in
+`evidence-anchor`, and the `CitationRecoveryAttempt` type vocabulary lives in
+`citation-engine`.
+
+## Decision
+
+### Owned by `evidence-source`
+
+| Capability | Module | Notes |
+| ---------- | ------ | ----- |
+| Re-ingest + re-fingerprint | `src/recovery/re-ingest.ts` | Compare prior vs. fresh `Document.fingerprint`; flag `requiresReanchor` |
+| Local canonical quote search | `src/recovery/local-search.ts` | Search `DocumentRepresentation.canonicalText` |
+| Recovery attempt scaffold | `src/recovery/attempt.ts` | Local record using SharedContracts state vocabulary |
+| Discovery hook interface | `src/recovery/discovery.ts` | Pluggable providers; **no network in core** |
+
+### Owned elsewhere
+
+| Capability | Owner |
+| ---------- | ----- |
+| Stale selector detection / resolution order | `evidence-anchor` |
+| `CitationRecoveryAttempt` canonical type | `citation-engine` (future) |
+| Human confirmation UX | `citation-work` / umbrella |
+| External HTTP/API lookup implementations | Deployment-specific providers registered on the hook |
+
+### Recovery flow
+
+```text
+CitationClue
+ → createRecoveryAttempt (evidence-source)
+ → local library search via searchCanonicalQuote (evidence-source)
+ → optional SourceDiscoveryHook providers (registered by host)
+ → reIngestAndCompare when fresh bytes arrive (evidence-source)
+ → anchor layer re-resolves selectors when requiresReanchor is true
+```
+
+`evidence-source` **does not** decide whether a selector is stale. It supplies
+primitives the anchor layer calls after it detects mismatch.
+
+## Consequences
+
+- No network coupling in the recovery core.
+- Host apps register discovery providers explicitly (local-first default).
+- Recovery state enum mirrors `wiki/SharedContracts.md` §2.6 locally until the
+ engine exports a shared `CitationRecoveryAttempt` type.
\ No newline at end of file
diff --git a/src/html/extract.ts b/src/html/extract.ts
new file mode 100644
index 0000000..887cb00
--- /dev/null
+++ b/src/html/extract.ts
@@ -0,0 +1,58 @@
+/**
+ * HTML text extraction → canonical text + gap-free offset map.
+ *
+ * Implements ADR-0003 for the `html-dom` representation. Active content is
+ * stripped before text extraction; canonical text is produced via `normalize()`.
+ */
+
+import { normalize, type OffsetMap } from "@citation-evidence/engine/shared";
+import { buildWholeTextOffsetMap } from "../shared/offset-map";
+
+const ACTIVE_CONTENT =
+ /<(script|style|iframe|object|embed|noscript)\b[^>]*>[\s\S]*?<\/\1>/gi;
+const BLOCK_BREAK = /<\/(p|div|h[1-6]|li|tr|section|article|blockquote|pre)>/gi;
+const BR_TAG = /
/gi;
+const TAG = /<[^>]+>/g;
+
+export interface HtmlExtractionResult {
+ readonly canonicalText: string;
+ readonly sanitizedHtml: string;
+ readonly offsetMap: OffsetMap;
+}
+
+export function extractHtml(source: string): HtmlExtractionResult {
+ const sanitizedHtml = sanitizeHtml(source);
+ const rawText = htmlToPlainText(sanitizedHtml);
+ const canonicalText = normalize(rawText).text;
+ return {
+ canonicalText,
+ sanitizedHtml,
+ offsetMap: buildWholeTextOffsetMap(canonicalText),
+ };
+}
+
+export function sanitizeHtml(source: string): string {
+ return source.replace(ACTIVE_CONTENT, "");
+}
+
+function htmlToPlainText(html: string): string {
+ let text = html.replace(BR_TAG, "\n").replace(BLOCK_BREAK, "\n\n");
+ text = text.replace(TAG, "");
+ return decodeHtmlEntities(text);
+}
+
+function decodeHtmlEntities(text: string): string {
+ return text
+ .replace(/ /gi, " ")
+ .replace(/&/gi, "&")
+ .replace(/</gi, "<")
+ .replace(/>/gi, ">")
+ .replace(/"/gi, '"')
+ .replace(/'/g, "'")
+ .replace(/([0-9a-f]+);/gi, (_, hex: string) =>
+ String.fromCodePoint(Number.parseInt(hex, 16)),
+ )
+ .replace(/(\d+);/g, (_, dec: string) =>
+ String.fromCodePoint(Number.parseInt(dec, 10)),
+ );
+}
\ No newline at end of file
diff --git a/src/html/ingest.test.ts b/src/html/ingest.test.ts
new file mode 100644
index 0000000..83fdd30
--- /dev/null
+++ b/src/html/ingest.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+
+import { fingerprintBytes } from "../pdf/fingerprint";
+import { HTML_FIXTURE } from "../../tests/fixtures-text";
+import { ingestHtml } from "./ingest";
+import { extractHtml, sanitizeHtml } from "./extract";
+
+describe("extractHtml", () => {
+ it("strips active content before text extraction", () => {
+ const { sanitizedHtml } = extractHtml(HTML_FIXTURE);
+ expect(sanitizeHtml(HTML_FIXTURE)).not.toMatch(/
+