|
| 1 | +--- |
| 2 | +status: Proposed |
| 3 | +date: 2026-05-19 |
| 4 | +deciders: |
| 5 | + - aaronsb |
| 6 | + - claude |
| 7 | +related: [] |
| 8 | +--- |
| 9 | + |
| 10 | +# ADR-203: Faithful-by-default content reads; concise-by-default tool results |
| 11 | + |
| 12 | +## Context |
| 13 | + |
| 14 | +#133 argues that because MCP is a machine-to-machine protocol, the default |
| 15 | +tool response should be structured/source data, and that forcing every |
| 16 | +client to pass `raw: true` everywhere pushes presentation concerns into |
| 17 | +every consumer. Its proposed remedy is a **global flip**: make `raw: true` |
| 18 | +behavior the default, opt into rendered output with `render: true`. |
| 19 | + |
| 20 | +Empirical inspection of 0.11.29 (live calls + the response code paths in |
| 21 | +`src/tools/semantic-tools.ts` and `src/utils/file-reader.ts`) shows the |
| 22 | +real situation is sharper than the issue states, and the global flip is the |
| 23 | +wrong lever: |
| 24 | + |
| 25 | +1. **The default content read is *lossy*, not merely "rendered."** Default |
| 26 | + `vault.read` does **not** return the file. `file-reader.ts` returns full |
| 27 | + content only when `returnFullFile` is set; otherwise it falls through to |
| 28 | + fragment retrieval, and the presentation facade then renders fragments |
| 29 | + with **newlines/whitespace flattened to spaces**. Measured on a tiny |
| 30 | + structured file, the default read collapsed frontmatter, headings, a |
| 31 | + fenced Python block (indentation lost) and a trailing tab onto a single |
| 32 | + line. An agent that derives an `edit.window` `oldText` from a default |
| 33 | + read gets text that **cannot match the file on disk**. This is a |
| 34 | + correctness defect in the read→edit round-trip, and a likely contributor |
| 35 | + to the `edit.window` fuzzy-match fragility the project has been |
| 36 | + compensating for elsewhere. |
| 37 | + |
| 38 | +2. **The faithful path is needlessly ~2× bloated.** With |
| 39 | + `returnFullFile: true, raw: true`, the complete file body is embedded |
| 40 | + **twice** — `result.content.content` and `result.metadata.content` are |
| 41 | + byte-identical — on top of `workflow`/`suggested_next`/`context` |
| 42 | + boilerplate. Roughly half of the "raw" read payload is pure duplication. |
| 43 | + |
| 44 | +3. **`returnFullFile` without `raw` is broken.** The presentation formatter |
| 45 | + throws on the full-file shape and emits a literal |
| 46 | + `_Formatter error, showing raw data:_` JSON fallback. There is no working |
| 47 | + concise full-file formatted view today. |
| 48 | + |
| 49 | +4. **A global flip would make tool *results* more verbose.** The default |
| 50 | + formatted path (`formatResponse`) is a deliberately condensed, |
| 51 | + token-efficient summary for action/result tools (create/edit/move/ |
| 52 | + search). `raw: true` produces the full pretty-printed envelope — |
| 53 | + *more* tokens. Flipping the global default to `raw` therefore works |
| 54 | + directly against response economy for the common case (#133's own stated |
| 55 | + value — "less verbose" — is served by the current result formatting, not |
| 56 | + by the flip). |
| 57 | + |
| 58 | +The intent behind #133 is correct (a machine protocol's default output |
| 59 | +must be machine-usable). The instinct that the project also wants — concise |
| 60 | +formatted output for tool *results*, but unaltered content for *editing* — |
| 61 | +is also correct. The two are reconciled by separating **content reads** |
| 62 | +from **action/result rendering** rather than by one blunt format switch. |
| 63 | + |
| 64 | +## Decision |
| 65 | + |
| 66 | +Adopt a **two-bucket response contract**. This ADR is the decision of |
| 67 | +record; implementation is tracked separately and is *not* part of this ADR. |
| 68 | + |
| 69 | +1. **Content reads are faithful by default.** `vault.read` returns the |
| 70 | + **complete, verbatim file source** by default — byte-exact as stored by |
| 71 | + Obsidian (newlines, indentation, trailing whitespace, tabs, fenced |
| 72 | + blocks, frontmatter delimiters preserved). Fragmentation / summarization |
| 73 | + becomes an **explicit opt-in** for large-file context savings via the |
| 74 | + *existing* knobs (`query`, `strategy`, `maxFragments`); `returnFullFile` |
| 75 | + is no longer required to get the truth and is retired or demoted to a |
| 76 | + no-op alias. The read→edit round-trip is correct by construction. |
| 77 | + |
| 78 | +2. **Action/result outputs stay concise-formatted by default.** Tools whose |
| 79 | + result is a status / confirmation / list (create, update, delete, move, |
| 80 | + rename, copy, search, graph.*, edit.*) keep the condensed |
| 81 | + `formatResponse` presentation as the default — the token-efficient |
| 82 | + ergonomics are intentional and retained. `raw: true` remains the |
| 83 | + explicit opt-in for the full structured envelope on these. #133's |
| 84 | + global-flip proposal is **declined**; its intent is satisfied by |
| 85 | + bucket 1. |
| 86 | + |
| 87 | +3. **Stop double-encoding the body.** The structured read envelope must not |
| 88 | + embed the file content twice. `metadata` carries metadata (path, |
| 89 | + wordCount, tags, frontmatter, warning) — not a second copy of the body. |
| 90 | + Orthogonal to the default change and applies regardless. |
| 91 | + |
| 92 | +4. **The full-source path must not depend on the broken formatter.** |
| 93 | + Verbatim content is returned through a path that does not throw the |
| 94 | + `_Formatter error_` fallback; the failing `returnFullFile` formatted |
| 95 | + branch is fixed or removed as part of (1). |
| 96 | + |
| 97 | +Backward compatibility: (1) changes the default shape/size of `vault.read` |
| 98 | +for clients that currently rely on the fragmented/flattened default. This |
| 99 | +is a deliberate breaking change to fix a correctness bug; it ships with a |
| 100 | +clear changelog entry and a documented opt-in (`strategy`/`maxFragments`) |
| 101 | +for the previous context-saving behaviour. A prerelease + functional |
| 102 | +verification of a real read→`edit.window` round-trip gates the rollout. |
| 103 | + |
| 104 | +#133 is **reframed, not closed by fiat**: it remains the user-facing |
| 105 | +tracking issue for bucket 1; this ADR records the agreed direction and |
| 106 | +explicitly supersedes its specific "global `raw` flip" remedy. |
| 107 | + |
| 108 | +## Consequences |
| 109 | + |
| 110 | +### Positive |
| 111 | + |
| 112 | +- Read→edit round-trips become correct by default: `edit.window`/`patch` |
| 113 | + `oldText` derived from a read matches disk. Removes a structural source |
| 114 | + of the fuzzy-match fragility. |
| 115 | +- Honours #133's intent (machine-usable default) without the verbosity |
| 116 | + regression a global flip would cause for action/result tools. |
| 117 | +- De-duplicating the body roughly halves structured-read payloads — a free |
| 118 | + win for every `raw` consumer, independent of the default change. |
| 119 | +- Fixes a latent crash (`returnFullFile` formatter throw). |
| 120 | + |
| 121 | +### Negative |
| 122 | + |
| 123 | +- Breaking change for clients depending on the current fragmented/flattened |
| 124 | + default `vault.read` (smaller, lossy payloads). Mitigated by changelog, |
| 125 | + the existing `strategy`/`maxFragments` opt-in for context savings, and a |
| 126 | + gated prerelease test. |
| 127 | +- Large files now return full content by default → higher per-call context |
| 128 | + cost for the read-everything pattern. Mitigated: fragmentation is still |
| 129 | + one explicit parameter away, and a `wordCount`/size `warning` is retained |
| 130 | + to nudge large-file callers toward it. |
| 131 | + |
| 132 | +### Neutral |
| 133 | + |
| 134 | +- Two-bucket contract should be documented in the README and tool |
| 135 | + descriptions so the default semantics are discoverable. |
| 136 | +- `returnFullFile` becomes redundant; retire or alias with a deprecation |
| 137 | + note. |
| 138 | +- No change to `IObsidianAPI` or the MCP tool surface beyond the default |
| 139 | + response shape/size of `vault.read` and the de-duplicated envelope. |
| 140 | + |
| 141 | +## Alternatives Considered |
| 142 | + |
| 143 | +- **#133 as written — global flip to `raw` default, `render:true` opt-in.** |
| 144 | + Declined: makes every action/result return more verbose (against the |
| 145 | + project's "concise tool results" goal), and still wouldn't fix the |
| 146 | + newline-flattening fidelity bug — the default read is lossy *before* it |
| 147 | + reaches the format switch. |
| 148 | +- **Status quo (require `returnFullFile:true` + `raw:true` for fidelity).** |
| 149 | + Rejected: read→edit correctness must not depend on two non-obvious |
| 150 | + opt-ins; this is the exact friction #133 documents and the latent cause |
| 151 | + of silent edit mismatches. |
| 152 | +- **Keep fragmented default but stop flattening newlines.** Rejected as |
| 153 | + insufficient: a fragment is still a *subset* of the file; an agent |
| 154 | + editing needs the whole, exact source, not faithfully-formatted excerpts. |
| 155 | +- **New dedicated `vault.source` tool, leave `read` lossy.** Rejected: |
| 156 | + splits the surface and leaves the obvious tool (`read`) a footgun; the |
| 157 | + principled fix is making the obvious tool correct by default. |
0 commit comments