io-harness 0.22.0

An embeddable agent runtime for Rust: any task, any provider, in your own process. Run commands, edit files and search a repository under a layered permission boundary on files, commands and network; gate the result on the project's own test command in any language, or on nothing at all; and keep a full SQLite trace of every step, refusal and budget draw. With an execution sandbox, contained sub-agents, an MCP client, and durable resume for unattended runs.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
# Public contract — IO Harness

What you may depend on, what may change, and what does not work today.

The crate is **pre-1.0 and stays pre-1.0** until its owner says otherwise. This
page exists because "pre-1.0" is usually where a library stops explaining
itself, and that is precisely when a dependent needs the explanation most.

## What is public

The public surface is everything re-exported from the crate root plus the items
reachable through the public modules it names.

The re-exported half — the 110 items a caller reaches as `io_harness::Thing` —
is enumerated in [public-api.txt](public-api.txt), which a test compares against
the live crate on every run. That is the surface the deprecation cycle below
covers and the surface every item of which carries a worked example.

The module-path half is narrower in practice and wider on paper: items such as
`io_harness::context::assemble` or `io_harness::tools::Workspace` are `pub` and
do compile, but they are not individually snapshotted. Treat them as public and
stable in the same way, and expect the snapshot to grow to cover them rather than
the items to be withdrawn.

Not public, and free to change without any notice:

- Anything not reachable from the crate root — private modules, `pub(crate)`
  items, and the internals of a public type.
- The exact wording of an error message, a log line, or a trace record's prose.
  The `Error` **variants** are public; the sentences they render are not.
- The SQLite schema. The store is an implementation detail reached through
  `Store`'s methods; the tables are not a contract, and `CHECKPOINT_FORMAT`
  exists precisely so a checkpoint written by an older version is refused with a
  typed error rather than half-read.
- The system prompt, the tool descriptions handed to a model, and the shape of a
  provider request body.

## What a version number means here

- **PATCH** — backward-compatible fixes.
- **MINOR** — new functionality, and, below 1.0, occasionally a break.
- **MAJOR** — reserved. There is no 1.0 planned.

SemVer permits a 0.x minor to break, and this project uses that permission
rather than pretending otherwise. A break arrives as a minor bump, never as a
patch.

**What you can rely on is not that a break will not happen. It is this:**

1. Every break is marked in the [CHANGELOG]../CHANGELOG.md against the version
   that made it.
2. Every marked break carries a migration note saying what to write instead —
   the old call on one side, the new call on the other. A test fails the build
   if a marked entry has no note.
3. A renamed or removed item goes through a deprecation cycle. It is marked
   `#[deprecated]` with the replacement named in the attribute for at least one
   minor release before it is removed, so an upgrade warns before it breaks.

The mechanism behind (3) is [public-api.txt](public-api.txt). Removing or
renaming a public item fails the test until that file is edited by hand — which
is the moment the deprecation attribute and the migration note get written,
rather than a moment nobody notices. There is deliberately no flag that
regenerates the snapshot automatically; a one-command regenerate would defeat
the mechanism the first time someone was in a hurry.

## Minimum supported Rust version

The MSRV is **1.88**, declared as `rust-version` in `Cargo.toml` and asserted
against this page by a test.

The floor comes from `rmcp`, which uses let-chains and publishes no
`rust-version` of its own, so cargo cannot catch it at resolve time — an older
toolchain fails inside that dependency rather than here. The other floors are
lower: `process-wrap` needs 1.87, `reqwest` 0.13 needs 1.85.

An MSRV raise is a **minor** bump and is called out in the changelog like any
other break.

## Feature flags

`default = []`. The default build compiles no optional dependency at all, and
enabling a feature only ever adds to the surface.

| Feature | What it adds | Cost |
| --- | --- | --- |
| `default` | Nothing. The empty set is deliberate — the default dependency tree is held at a fixed size and checked | None |
| `media` | Image passthrough: `Media`, `IMAGE_MEDIA_TYPES`, and the `view_image` built-in | `base64`, already compiled transitively by `reqwest` |
| `documents` | Umbrella over the five below | The union of theirs |
| `xlsx` | Spreadsheet read, generate, and preserving single-cell edit | `calamine`, `rust_xlsxwriter`, `umya-spreadsheet` — three crates because reading, writing, and round-tripping are three separate capabilities in this ecosystem |
| `docx` | Word read and generate | `docx-rs` |
| `pptx` | PowerPoint text extraction, read-only | `zip`, `quick-xml` |
| `pdf` | PDF generate, extract text, watermark, fill AcroForm fields | `lopdf`, `pdf-extract` |
| `barcode` | Barcode and QR decoding from an image | `rxing`, `image` |

Nothing here binds a C or C++ library, so no runner needs a system package.

`Workspace::read_bytes`, `Workspace::write_bytes` and
`Verification::DocumentContains` are present in **every** build. Without the
features, `DocumentContains` returns a typed error naming the missing feature
rather than the variant disappearing — a conditional enum variant is a breaking
change for every `match` a caller wrote.

## Platform support

| Platform | Status | Sandbox containment |
| --- | --- | --- |
| macOS | Supported, full suite in CI | Native, `sandbox-exec` |
| Linux | Supported, full suite in CI | Native, namespaces and rlimits |
| Windows | Supported, full suite in CI | **Portable floor only** |

On Windows the Job Object backend is designed and not implemented. Containment
there is the portable floor: an ephemeral working directory, network denied, and
the wall clock. CPU and memory caps **do not fire**. Do not read "sandboxed" on
Windows as "resource-capped".

## Limits that hold today

Stated here rather than discovered later. Each is real, each is known, and none
is fixed as of 0.22.0.

**What a provider-executed search or fetch is, and is not (0.22.0).** A
`WebAccess` on the contract asks the *provider* to look something up inside the
completion:

- **The provider dials the URL, so this process never does.** No socket is opened
  here for a search or a fetch, `Act::Net` is therefore never consulted for one,
  no `Approver` sees it, and the sandbox is not involved. The boundary is
  **declared, not enforced by this crate**: `allowed_domains` and
  `blocked_domains` fill in the vendor's own filter and are enforced there. It is
  the arrangement already stated below for a stdio MCP server — the harness states
  a boundary another process enforces, and records what it stated. Enforcement in
  *this* process means not turning the feature on and using a tool the harness
  executes itself.
- **A declaration a vendor cannot carry is refused, not narrowed.** OpenAI's Chat
  Completions takes an allow-list and has no fetch tool; OpenRouter's web plugin
  takes no domain filter and has no fetch tool. Either mismatch is an
  `Error::Config` before the first request is sent, so a `WebAccess` is not
  automatically portable between providers.
- **A citation is what the provider returned, and is not verified here.** The
  crate does not fetch the cited URL, does not check that the page says what the
  model claimed, and does not rank what it was given. A `citations` row is
  evidence about the answer, not about the world, and nothing in the run consults
  one.
- **A paused turn resumes as a fresh request and may repeat a search.** A
  `pause_turn` stop reason is a continuation, so the loop takes another step — but
  the request has been one flattened user turn since 0.1.0 and the crate does not
  echo the vendor's partial assistant blocks back. The provider may therefore
  re-run, and re-charge for, a search it already performed. `WebAccess::max_uses`
  is the only lever against it.
- **A spawned child inherits the root's declaration and cannot ask for its own.**
  The spawn tool copies the root contract's `WebAccess` onto the child and never
  reads one from the spawn arguments, so the model cannot widen it; there is no
  per-child narrowing either. A plain `Session::turn` has no declaration, because
  it builds its own contract — `turn_bounded` carries one.

See the [web guide](guide/web.md).

**What a session is, and is not (0.20.0).** A [`Session`] is a conversation over
the runs, not a second execution path:

- **A turn is a run.** Each turn has its own `runs` row, steps, refusals, budget
  draws and checkpoint, and carries every guarantee a run carries — it is
  auditable, resumable by its `run_id` through the ordinary `resume*` entry
  points, and bounded by the same `Policy`. A session adds the tree and nothing
  else.
- **The tree is append-only.** Branching makes an earlier turn the parent of the
  next one; it edits, deletes and rewrites nothing, so an abandoned branch stays
  readable. There is no turn edit, no history rewrite, and no session-level
  compaction — what bounds a long conversation is the `ContextBudget`, which
  elides what the model sees and never what the store holds.
- **A streamed delta is provisional until the completion returns.**
  `EventKind::Token` is what the model has said so far, not a decision it has
  made: the turn may still fall over to another provider, be retried, or be
  interrupted, and text already emitted is not withdrawn. Render it; do not act on
  it. The committed step is the settled fact.
- **A `Provider` that does not override `complete_streaming` streams nothing.**
  The default emits the finished text as one delta, which keeps a consumer
  rendering, and is not incremental. The three built-in providers and `Fallback`
  override it.
- **Steering is text, not authorization.** An operator's mid-turn message reaches
  the model exactly as a `TaskContract` constraint does, and every tool call it
  leads to is checked against the same policy by the same code. A `Steer` cannot
  change the policy, the budgets, the sandbox or the contract of a turn in flight.
- **A steer and an interrupt land at the next step boundary**, never where they
  were sent — the same rule `Flow::Cancel` has always had, for the same reason: in
  between, a tool call is in flight and a file may be half-written.
- **One session, one driver.** Two processes taking turns on the same session id
  concurrently is unsupported and undefended beyond SQLite's own busy timeout;
  their turns would interleave into one tree in an order nobody chose.
- **A session has no aggregate budget.** Every turn is a fresh run with its own
  ceilings, so `max_steps` on one turn does not bound the next. A conversation-wide
  limit is the caller's to enforce, per turn, from `Store::run_summary`.

**What configuration is, and is not (0.19.0).** `io.toml` is a projection onto
the typed API and never a second path into the run loop:

- **The typed API is the authority.** Every key lands in a type this crate
  already had — `Policy`, `SandboxConfig`, `Toolchain`, `PriceTable`,
  `McpServer`, `TaskContract` — and a file can express nothing the typed API
  cannot. A test asserts every key reaches a typed field.
- **The file is read once, by the caller, before the run, and never again.**
  Nothing in this crate discovers a config on its own: `Config::discover` is the
  caller's own call. That is what makes the one guarantee here true — a config
  the agent writes *during* a run cannot widen the boundary that run is already
  under.
- **A config file is not a security boundary against the agent.** The boundary is
  the `Policy` the caller loaded; the file is where it was written down. An agent
  that can write the workspace root can write an `io.toml`, and the *next* load —
  the caller's act, not the agent's — will read it.
- **An unknown key is an error**, naming the key and the file, rather than being
  ignored. The exception is a key inside a `[[mcp]]` table, because `McpServer`
  is `#[serde(flatten)]`-based and serde refuses `flatten` beside
  `deny_unknown_fields`.
- **A failed substitution is an error, never an empty string.** An unset
  `${env:...}`, an unreadable `${file:...}`, and a value that resolves to nothing
  each fail the load, because an empty string in a boundary rule is a rule that
  matches nothing.
- **The `[toolchain]` override does not reach this crate's own run loop.** The
  harness detects for itself; `Config::toolchain(detected)` gives the embedding
  application the merged value. Wiring it into the loop needs a new
  `TaskContract` field, which is a break, and 0.19.0 carries none.
- **Scope discovery is fixed**: four scopes, no `include`, no `extends`, no
  parent-directory search, no JSON or YAML form, and no reload.

**What every recorded number is, and is not (0.18.0).** The trace now answers
what a run cost and which model spent it, and the provenance of each figure
matters more than the figure:

- A **token count is the provider's report**, not this crate's measurement.
  `Usage` is `None` where the provider reported nothing, which is not the same
  fact as zero, and `total_tokens` is taken as reported rather than re-derived
  from the parts — a vendor whose total disagrees with its own breakdown is
  billing on the total.
- A **latency is the harness's own wall clock**, bracketing `Provider::complete`.
  It includes this crate's request building and stream consumption, so it is
  slightly above what the vendor would call its own latency, by design: it is the
  wait a caller actually experiences.
- A **TTFT is `None`, never zero, where nothing measured it** — a provider that
  does not stream, or a test double. An unmeasured wait and an instant one are
  different facts.
- **Cache and reasoning counters are breakdowns**, not additions: cache tokens of
  `prompt_tokens`, reasoning tokens of `completion_tokens`. Anthropic reports its
  cache counts *beside* a prompt count that excludes them and the crate
  reconciles that at the wire boundary, so a row does not mean two things
  depending on which vendor wrote it.
- **`server_tool_requests` is only as complete as the vendor's reporting.** It is
  non-zero from 0.22.0, where a provider both executes a tool and reports a count
  for it — Anthropic does. OpenAI and OpenRouter report no such counter in the
  shape the crate reads, so the meter is zero on those providers even for a run
  whose `server_tool_calls` rows say a search ran. A provider-executed request is
  billed per request rather than per token, and a `PriceTable` prices tokens: no
  derived cost includes it.
- A **cost is derived, never stored**, from a price table the operator supplies,
  and is therefore only as right as that table. The crate ships **no prices** and
  requires an as-of date on any table, because it cannot keep a vendor's price
  list accurate on its own release schedule. An unpriced call is counted in
  `Spend::unpriced_calls` rather than costed at zero: a group with calls there is
  reporting a floor, not a total.
- **A run recorded before 0.18.0 has no rows at all**, in either new table.
  Nothing is backfilled, because the facts were never recorded. The queries
  return nothing rather than zeros.

**Lines added and removed are not a minimal diff.** `Edit::measure` compares the
file's lines before and after and trims the common head and tail. A one-line
replacement is one added and one removed; a rewrite of the middle of a file is
the size of that middle. It is a size, not a patch.

**What a passing verification gate proves.** It proves the criterion ran against
the subject and reported success. It does not prove the implementation is
correct, idiomatic, or complete. A gate is a check the caller wrote, and it
proves exactly what that check tested.

As of 0.18.0 a criterion is a command — the project's own runner, in its own
process — or `EachCompilesRust`, the one gate the harness still spawns `rustc`
for. The class of bypass 0.8.1 hardened against is therefore **structurally
gone** rather than defended against: it required a caller-supplied criterion
compiled into the subject's own crate, and no criterion is any more. What
replaces that guarantee is ordinary discipline about where the criterion lives —
a gate the agent is permitted to edit is not a gate, whatever the harness does.
`TEST_BINARY` still exists so that policies written against it compile, but
**nothing spawns it**: denying it changes nothing. See the
[verification guide](guide/verification.md).

**What the permission policy governs.** It decides whether an action is taken:
which paths are read and written, which binaries and tools may be invoked, which
hosts may be dialled. It does not govern what a thing does once it is running. A
registered `Tool` executes in the harness's own process with the embedding
program's privileges; a stdio MCP server is a separate process that, once
started, dials what it likes; and a provider-executed search or fetch (0.22.0) is
dialled by the provider, so no `Act::Net` decision is taken for it at all and its
domain filter is the vendor's rather than the policy's. The harness decides what
starts, not what a started thing then does.

**What a command the agent runs is bounded by.** As of 0.17.0 the agent can run
a command with the `exec` tool. Every call is an `Act::Exec` check on the program
*and* on the whole argv, so `allow_exec("cargo test*")` beside
`deny_exec("cargo publish*")` means what it reads. A refusal, and an approver's
decision, land in `policy_events` attributed to the rule and layer; a silent
allow does not write a row, exactly as it does not for a read or a write. What
the policy does **not** decide is what the command then does.

A command runs **in the workspace root with the embedding program's privileges,
outside the sandbox**. That is the same bound already stated above for a
registered `Tool` and a stdio MCP server, and it is deliberate: the sandbox
denies network egress and confines writes to its own workdir, which is right for
a verification gate and makes `npm install` impossible. Three consequences worth
naming. A policy written for file access does not constrain command execution —
`Act::Read`/`Act::Write` rules say nothing about `exec`, and the tier default
decides everything unnamed. A command can reach what the agent's own file rules
would have refused, because `cat secrets/prod.env` is a command and not a read.
And a timeout kills the **direct child only**: a process that child started
itself may outlive it, on every platform this crate supports — the same gap the
sandbox reports for its own wall-clock kill on the portable floor. See the
[command execution guide](guide/command-execution.md).

**Toolchain detection is a default, and it will be wrong for someone.** The
shipped table maps one marker file in the workspace root to an ecosystem and its
conventional commands, and puts that in front of the model. The harness never
runs those commands itself. Ecosystems disagree with themselves — half of all
`npm test` scripts do not run tests — and only the root is examined, so a
monorepo whose packages each carry a marker gets one detection. It is not
overridable until 0.19.0 puts a configuration file under it.

**Closed in 0.17.0: a registered tool could be silently shadowed.** The reserved
set named only the original seven built-ins while dispatch grew to twenty-six, so
a registered tool called `git_status` or `xlsx_read` passed `Toolbox::validate`
and was then permanently unreachable — the built-in answered every call. It now
names every built-in, in every build regardless of feature flags, and a
registered tool taking one of those names fails the run before the first
completion. The names of feature-gated built-ins are reserved even where the tool
itself is not compiled in, so enabling a feature can never take away a tool that
was working.

**Windows resource caps.** See the platform table above.

**`SandboxLimits::max_processes` is enforced by nothing**, on any platform. It is
deliberately not mapped to `RLIMIT_NPROC`, which is per-real-uid and would
throttle the operator's own login session rather than the sandbox; the two
backends that could scope it properly — the Linux pid-namespace active-process
limit and the Windows Job Object — are not wired up. Setting it changes nothing.

**No seccomp filter is installed.** The Linux backend is namespaces and rlimits.
Whatever syscall restriction applies is the kernel's own default under an
unprivileged user namespace, not a filter this crate installed.

**A native backend can silently become the floor.** `select` chooses its
candidate at compile time, and a backend whose primitive is unavailable at
runtime degrades to the portable floor — this is live on Ubuntu 24.04, where
`apparmor_restrict_unprivileged_userns=1` makes every `unshare` fail. It reports
the floor honestly in the returned `Selected`, so read that value rather than
assuming the platform's native backend is what ran.

**What the trace says a tree ran under.** `run_tree` and the single-file and
workspace loops record the policy they execute under, so the store answers "what
boundary was in force". `resume_tree` does not: it takes a policy as an argument
and executes under it, but leaves the recorded policy as whatever the run
started with. A tree resumed under a *widened* policy therefore leaves an audit
that understates what was permitted. `resume_tree_from_stored_policy` is
unaffected — it reads that same row back, so what is recorded is what runs — and
it is the entry point to prefer when the boundary matters.

**What a plan is, and is not (0.21.0).** The `todo_write` tool holds the agent's
plan in a `todos` table, replaced wholesale on every write.

- **A plan is never enforced.** Nothing verifies it, no `RunOutcome` depends on
  it, and no refusal consults it. An item whose state is `done` is the agent's
  claim, not a fact the harness checked. What a plan buys is a long run that can
  be recognised as going the wrong way before it ends.
- **A plan is not gated, and neither is a question.** Both write into the
  harness's own store rather than the workspace, the network or a binary, so
  there is no `Act` to check them against. There is deliberately no fifth `Act`
  variant: a permission rule in front of the channel whose purpose is to ask a
  human something would be a category error.
- **A plan is readable while the run is going.** The write is one transaction, so
  a reader on another connection sees the previous plan or the next one and never
  half of each.

**An answer to a question is text, never authorization (0.21.0).** The approval
path asks whether an action is *permitted* and its answer can only narrow what
happens. `ask_question` asks what the operator *wanted*, and its answer is
delivered to the model as an observation. Every tool call that follows one is
checked against the same `Policy` by the same code — the rule steering has
followed since 0.20.0. A human answering "write the file, I authorize it" does
not make a denied write permitted.

- **A paused question's step is committed, so the asking call is not replayed.**
  `resume_with_answer` therefore delivers the answer as a ledger observation
  rather than by re-running the tool. `Store::answered_question` is a query for
  reconstructing a run, not the resume mechanism.
- **`answered_by` distinguishes a machine from a person.** A `Responder` in the
  run's own process and a human answering after a pause are different facts about
  a run, and the trace keeps them apart.
- **A child's question pauses the whole tree**, as a child's deferred approval
  does, and `resume_tree_with_answer` takes the *root's* run id.

**What a named agent definition can and cannot do (0.21.0).** An `AgentDef` gives
a spawned child a role, a model and a narrower boundary.

- **A definition can only narrow.** `deny_write` and `deny_net` compose through
  `Policy::contain`, which has bounded every child since 0.5.0 — allows
  intersect, denies union, at any depth. There is no `allow_write` and no
  `allow_net`, and there must never be one: a roster in a configuration file that
  could grant would be a privilege-escalation path. A definition silent about a
  path its parent denies still yields a child that is refused it.
- **A model is a request, not a fact.** `AgentDef::model` travels as
  `CompletionRequest::model`, which a vendor may substitute or alias. What
  actually served a call is `CompletionResponse::model`, and that is what the
  trace records.
- **One provider serves a whole tree.** A definition names a model *string*; it
  cannot carry its own provider, vendor or API key. Two definitions naming models
  from different vendors is not supported.
- **A definition cannot carry its own skills directory.** The tree's skill
  catalogue is the root's and is shared, as it has been since 0.5.0.
- **The roster is advertised in the spawn tool's description, not as a schema
  `enum`**, so an unknown name is a recoverable error observation naming what is
  available rather than a malformed call.

**What a prompt template is (0.21.0).** `Templates::render` substitutes
`{{placeholder}}` values and routes the remainder to `$ARGUMENTS`, and returns a
`String`.

- **Rendering can set nothing.** No policy, no budget, no toolbox, no model, no
  verification criterion — it returns text, which is what makes a shared template
  directory safe to read.
- **A placeholder with no argument is an error, never an empty string** — the rule
  0.19.0 set for `${env:}`, for the same reason: a goal with a hole in it still
  reads like a goal.
- **There is no template language.** No conditionals, loops, includes, partials or
  nesting, and substitution is single-pass: a value containing `{{x}}` is emitted
  literally rather than re-read.

**Repetition detection does not survive a resume (0.21.0).** There are now two
stall signals: the 0.11.0 window (the workspace stayed still *and* a call
repeated) and bare repetition (the same call `window` times in a row, whether or
not the workspace moved — the shape a parent respawning one child takes, which
the window could never see because a child that ran sets `changed`
unconditionally). Both live in `Progress`, which is in-memory, so a resumed run
starts its window at zero exactly as it did before.

**`git_status` output.** It uses `--porcelain=v1` without `-z`, so a path
containing a newline renders ambiguously to the model. A display concern, not a
boundary one — the path policy still sees the real path.

**Git commit idempotency across a resume.** A replayed run does not commit
twice, but that rests on git's own semantics — a replayed `add` stages nothing,
so the replayed commit finds nothing staged — rather than on a durable marker
around the commit. It is tested and it holds. A future git built-in that is not
naturally idempotent would not inherit the property.

**PDF form filling.** `fill_form` generates real appearance streams, but the
output has never been opened in a real PDF viewer. Proving that needs Pdfium,
which is a system package, which this project does not permit on any runner.

**`pdf::read_text` panic containment.** Its `catch_unwind` is a no-op under a
`panic = "abort"` profile. A malformed PDF that panics the extractor will abort
the process rather than return an error, if you build with abort.

**Document fixtures.** Every binary document fixture in the test suite was
written by this crate or by a Rust library. None was produced by Excel, Word, or
Acrobat, so real-world quirks those applications emit are untested.

## Related

- [CHANGELOG.md]../CHANGELOG.md — the release history, with a migration note on
  every break.
- [CAPABILITIES.md]CAPABILITIES.md — the guide index.
- [RELEASE_PROCESS.md]RELEASE_PROCESS.md — how a release is cut.
- [public-api.txt]public-api.txt — the enumerated public surface.