# hu13 deps decision (M1/R1 spike, f0rge issue #1, read-only)

Spiked 2026-09-10 in scratch cargo project under `/tmp` (not in repo)
against real f0rge data at `~/src/code/gl0w-w0rld/f0rge/.forge`.
Total direct deps: **4** (limit ≤ 5). All pins exact (`=`), f0rge-style.

## Pinned set

```toml
[dependencies]
pulldown-cmark = "=0.13.4"
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.151"
yaml_serde = "=0.10.7"
# dates: hand-rolled, zero deps (copy f0rge-core is_rfc3339_utc shape)
# serve: std only (TcpListener + thread-per-connection), no tokio/hyper
```

`serde`/`serde_json` pins match f0rge workspace pins on purpose
(`f0rge/Cargo.toml`: `serde =1.0.229`, `serde_json =1.0.151`).
`yaml_serde =0.10.7` is f0rge's YAML pin (the maintained
`serde_yaml` rename); spike ran on `serde_yaml =0.9.34` then
verified `yaml_serde =0.10.7` drop-in (see §2).

## 1. Markdown → HTML: pulldown-cmark =0.13.4

Why: CommonMark-faithful, zero-JS output, no network/build deps,
`Options::empty()` is enough for f0rge bodies + wiki; already proven
at this version via `cargo fetch` + `cargo run`.

Spike: rendered real `issues/<uuid>/issue.md` body (164 B md →
171 B html), real `prs/<uuid>/pr.md` body (202 B → 210 B), and full
`wiki/docs/PLAN.md` (21598 B → 25377 B). All produced `<p>`/`<h1>`.
Code spans escape correctly (`` `<script>` `` → `<code>&lt;script&gt;</code>`).

XSS finding (honest): **raw `<script>` is NOT escaped by default.**
Probe `# hi / <script>alert(1)</script> / <img src=x onerror=…>` →
`"<h1>hi</h1>\n<script>alert(1)</script>\n<img …>\n"`
(raw present, escaped absent). This is correct CommonMark
passthrough, but wrong for hu13's untrusted inbox-adjacent bodies.
Fix (spiked, zero extra dep): map
`Event::Html(h)/InlineHtml(h) → Event::Html(escape_html(&h))`
with a 5-line hand-rolled `escape_html` (`&<>"'`), since mapping to
`Text` double-escapes. Verified safe output:
`"&lt;script&gt;alert(1)&lt;/script&gt;"`, raw absent, legit
`**bold**`/lists intact. Diffs/logs never go through markdown —
always `<pre>` + same `escape_html`.

Fallback: `pulldown-cmark =0.12.1` (same `Parser/html::push_html`
API for our use). Never comrak (bigger deps, server-side novelty
we don't need).

## 2. Frontmatter: split `---` + yaml_serde =0.10.7

Why: f0rge schema is `---` fences + YAML body; `yaml_serde` is the
maintained `serde_yaml` (0.9.x is `+deprecated`), same API, and the
exact crate f0rge-core pins, so hu13 parses byte-identically.

Spike (`serde_yaml =0.9.34`, `serde =1.0.229`, `serde_json =1.0.151`):
25/25 real files parsed — 12× `issues/*/issue.md` + 5× `prs/*/pr.md`
+ 6× `tasks/*.md` + `labels.md` (4 entries, YAML list, no fences) +
`milestones.md` (2 entries). Typed structs with `#[serde(default)]`
for optional `labels/assignees/milestone/updated`.
Equivalence probe on `yaml_serde =0.10.7`: unknown field tolerated,
`missing field \`id\`` error identical — drop-in confirmed.

- Unknown fields tolerated: `zzz_future: 1` → `Ok` (true).
- Missing required field names file+field: hu13 formats as
  `{basename}: {serde msg}` → e.g. `missing-title.md: missing field \`title\``
  (`msg.contains("title")` true), matching f0rge `MissingField{file,field}`.
- Zero panics: 10 hostile inputs via `catch_unwind`, 0 panics —
  `spec/fixtures/invalid/{bad-ts,bad-uuid,missing-title}.md`,
  `spec/fixtures/sec/alias-bomb.md`, golden `issue.md` (control ok),
  plus `""`, `---/---`, unclosed fence (→ `nofront`), NUL bytes
  (→ clean `Err`, no panic), 200 KB single line (→ `Ok`, cap handles
  before parse per schema SEC-01 256 KiB).

Fallback: `serde_yaml =0.9.34` (identical API, deprecated warning
only). Keep f0rge's pre-parse guards (size cap, indent/flow depth,
alias count) in hu13 build — YAML lib alone is not the guard.

## 3. Dates: hand-rolled RFC3339-UTC (no chrono/jiff) + serde_json =1.0.151

Why hand-rolled: schema needs only validation + `(created,id)` sort,
and lexicographic == chronological for fixed-width UTC output.
f0rge-core already proves it (`is_rfc3339_utc`, `unix_to_rfc3339`,
`now_rfc3339`, `forbid(unsafe_code)`); adding chrono/jiff buys
arithmetic/timezones hu13 never uses, costs a 5th dep + binary size.
Spike validator (f0rge shape: `YYYY-MM-DDTHH:MM:SS[.1-9frac]Z`):
23/23 real `created` fields ok, 0 bad; `08/09/2025 02:00` correctly
rejected. `serde_json` roundtrip (`json!` → `to_string` →
`from_str`) ok.

Fallback: `chrono =0.4.x` with `features=["serde"]` + `DateTime<Utc>`
if arithmetic ever needed; prefer jiff over chrono only if a date
dep becomes mandatory (smaller, safer API). Not needed for v1.

## 4. Serve: std TcpListener + thread-per-connection, no tokio/hyper

No code in this spike, decision only: the only dynamic surface is
2 write-only text POST routes (issue/PR inbox → `/data/inbox`,
never read back over HTTP, never rendered). Traffic is human +
occasional agent curl — single-digit req/s. A std `TcpListener`
accept loop + `thread::spawn` per connection + ~50-line HTTP/1.1
parser (`Content-Length` only, urlencoded form, 256 KiB cap, no
keep-alive tricks) is enough; static site is prebuilt files.
tokio/hyper add async runtime + dep tree for zero benefit here.
Fallback: `tiny_http` single-file crate if std parser ever feels
hand-rolled-critical — still no async.
