--- id: "012" title: Refs Without an Escaping Layer status: pending amends: specs/001-agent-mail-cli.md blocked_by: [] blocks: [] --- # Refs Without an Escaping Layer ## Overview Issue [#3](https://github.com/JuanjoFuchs/agent-mail-cli/issues/3) reports that one cross-project handoff took five attempts. Four were the caller's own misreads of a documented rule. The fifth is a defect, and it is the one that could not be recovered from. `--refs` takes a JSON array in a **single argv slot**. On Windows shells the escaping required to put a backslash inside that JSON does not survive the shell. Measured on this machine in Git Bash, both of these arrive at `argv` with single backslashes, and both fail: | What the caller types | What `argv` receives | Result | |---|---|---| | `'["D:\work\proj\x.md"]'` | `["D:\work\proj\x.md"]` | `Invalid \escape` | | `'["D:\\work\\proj\\x.md"]'` | `["D:\work\proj\x.md"]` | `Invalid \escape` | There is no third form. The caller cannot escape their way out, and the CLI's error — accurate as it is — names JSON rather than the shell, so it points at the wrong layer. What makes this sharp rather than cosmetic is that [spec 010](010-messages-are-not-documents.md) made absolute paths **mandatory**, precisely so a recipient in another project can open a ref. On Windows an absolute path contains backslashes. **The documented, required usage is the one that fails**, and the only workaround — forward slashes in a Windows path — is findable solely by guessing. The report carries a second finding: validation stops at the first problem, so attempts 3, 4 and 5 differed only in `--refs` while every one of those problems was knowable before anything was written. Ships as `0.5.1`. Additive: no existing invocation changes meaning. > **Completion rule:** This spec is not complete until all acceptance criteria are verified through the testing approach below, including a Windows absolute path with backslashes passed through a real Git Bash invocation. Build-only verification is insufficient. ## Goals - A path can be handed to `send` without surviving an escaping layer. - The usage the schema recommends is the usage that works, on every supported shell. - One malformed `send` reports every input problem it has, not the first one. ## Requirements ### Functional Requirements - **FR1**: `send` accepts a repeatable `--ref ` taking exactly one path per occurrence, with no JSON, quoting, or escaping layer beyond the shell's own argument splitting. - **FR2**: `--ref` entries are resolved and verified exactly as `--refs` entries are, and stored identically, so a recipient cannot tell which flag produced them. - **FR3**: `--refs` continues to work unchanged for callers already using it. - **FR4**: Supplying both `--ref` and `--refs` in one invocation is an error naming both. - **FR5**: `send` validates every input it can check before writing, and reports all failures in one response. - **FR6**: A single failure produces exactly the message it produces today; the aggregate form appears only when more than one problem is found. - **FR7**: `describe` presents `--ref` as the recommended form and records why `--refs` is hazardous on Windows shells. ### Non-Functional Requirements - **NFR1**: The JSON output and error contract is unchanged (spec 001 NFR1, NFR2): one JSON object on stderr with an `error` key and a non-zero exit. - **NFR2**: No new runtime dependencies, and no changes to the CI, release, npm publish, or WinGet workflows. - **NFR3**: A rejected `send` stores nothing, per spec 004's commit-after-output rule. ### Technical Constraints - **TC1**: `--ref` is repeatable; each occurrence carries one path, taken verbatim from `argv`. - **TC2**: Both flags produce the same stored value: a JSON array of resolved absolute paths, in the order given (spec 010 TC4). - **TC3**: Ref validation rules are unchanged — empty, unresolvable, or non-existent entries are errors — and the message names whichever flag the caller actually used. - **TC4**: The aggregated checks are the pure input checks knowable before any write: identity grammar for `--from` and `--to`, control characters and length for `--subject`, the `--body`/`--body-file` conflict, `--body-file` readability, control characters and length for the resolved body, the ref flag conflict, ref parseability, ref resolution and existence, and `--reply-to` UUID shape. - **TC5**: A check whose input never materialised is skipped rather than reported — an unreadable `--body-file` yields one problem, not a second about the body it could not produce. - **TC6**: No `--dry-run` is added to `send`. See Key Decisions. - **TC7**: Ships as `0.5.1` through the existing PyPI, GitHub Release, and npm channels. ### Requirement Traceability | Requirement | Acceptance Criteria | |---|---| | FR1 | AC1, AC2 | | FR2 | AC3 | | FR3 | AC4 | | FR4 | AC5 | | FR5 | AC6, AC7 | | FR6 | AC8 | | FR7 | AC9 | | NFR1 | AC7, AC8 | | NFR2 | AC11 | | NFR3 | AC10 | | TC1 | AC1, AC2 | | TC2 | AC3 | | TC3 | AC5 | | TC4 | AC6 | | TC5 | AC7 | | TC6 | AC9 | | TC7 | AC12 | ## Reported Shape Two problems in one send, reported together: ```json { "error": "2 problems with this send:\n\n1) Body is 2320 characters; the limit is 2000. ...\n\n2) --ref entry does not exist: 'specs/028.md' (resolved to ...). ..." } ``` The working form, with no escaping layer: ```bash agent-mail send --from a:b --to c:d --subject "Handoff" --body "See refs." \ --ref "D:\work\proj\specs\028.md" --ref "D:\work\proj\notebook.ipynb" ``` ## Key Decisions ### One path per argv slot, because argv is the one layer the shell cannot corrupt The bug is not JSON's fault and not the shell's; it is the decision to nest a structured format inside a single argument. Every escaping scheme then has to survive whatever the caller's shell does to quotes and backslashes, and Git Bash on Windows collapses the exact sequence a Windows path needs. Moving to one path per occurrence removes the layer entirely: the shell already splits arguments, and that is the mechanism the caller can reason about. This is the rule the repo keeps rediscovering, applied to input rather than output — a surface that lets an agent get it wrong will be got wrong, and the fix is to make the wrong form unnecessary rather than to document the escaping. ### `--refs` stays, and is not deprecated by warning It works fine on POSIX shells, it is in published examples, and scripts use it. There is no warning channel in a JSON-only CLI, and inventing one to nag about a working flag would cost more than it saves. `describe` marks `--ref` as the recommended form and states the Windows hazard, which is where an agent actually looks. ### The two flags do not combine Merging them would raise an ordering question nobody needs answered, and the repo already has the precedent: `--body` and `--body-file` refuse each other rather than concatenating. One way to express one thing per invocation. ### Report every problem, but only summarise when there is more than one Existing errors are carefully written — the body cap names the length, the limit, the remedy, and the evasions it refuses. Collapsing those into terse list entries to make aggregation uniform would trade a good message for a consistent one. So a single problem is reported exactly as it is today, and the numbered aggregate appears only when it has something to aggregate. ### No `--dry-run` on `send` The spec 001 audit declined it on the grounds that `send` is not destructive and a dry run invites an agent to spend a turn validating something cheap to retry. That reasoning still holds, and aggregate validation removes the motivation the report gave for it: the five-attempt run becomes two because the second attempt is told everything, not because a rehearsal was available. ### The error names the flag the caller used An agent that passed `--ref` and reads "`--refs` entry does not exist" has been handed a second puzzle. The message is parameterised on the flag actually used. ## Implementation Tasks - [ ] Add a repeatable `--ref` to `send`, sharing resolution and storage with `--refs`. - [ ] Reject the two flags together, naming both. - [ ] Split each input validator into a pure check that returns a message and a wrapper that exits, so `send` can collect instead of exiting on the first failure. - [ ] Aggregate the checks in `send` and report them in one response, numbered only when there is more than one. - [ ] Record `--ref` as the recommended form in the schema, with the Windows hazard on `--refs`, and update the examples. - [ ] Add regression coverage for every acceptance criterion below. - [ ] Amend spec 001 with the repeatable flag and the aggregate-validation rule. - [ ] Bump the package version to `0.5.1` and add the CHANGELOG entry. - [ ] Release `v0.5.1` after JJ's approval and verify the published artifacts. - [ ] Close issue #3 referencing the released version. ## Acceptance Criteria - [ ] **AC1** (`integration`): `send --ref ` accepts one existing path and stores it resolved to absolute. - [ ] **AC2** (`integration`): `--ref` repeats, and three occurrences store three paths in the order given. - [ ] **AC3** (`integration`): a message sent with `--ref` is indistinguishable, as stored and as read, from the same message sent with `--refs`. - [ ] **AC4** (`integration`): every existing `--refs` invocation behaves exactly as before, including its error messages. - [ ] **AC5** (`integration`): `--ref` together with `--refs` is a JSON error naming both flags and storing nothing; a missing `--ref` path is an error naming `--ref` rather than `--refs`. - [ ] **AC6** (`integration`): a send carrying an over-length body, a missing ref, and an invalid recipient reports all three problems in one response, numbered, and stores nothing. - [ ] **AC7** (`integration`): an unreadable `--body-file` produces one problem, not a second about the body that could not be read. - [ ] **AC8** (`integration`): a send with exactly one problem produces the same message as the current release — no numbering, no preamble. - [ ] **AC9** (`integration`): `describe` documents `--ref` as recommended, states the Windows hazard on `--refs`, and `send` has no `--dry-run`. - [ ] **AC10** (`integration`): every rejected send above leaves the mailbox byte-identical, verified by direct database inspection. - [ ] **AC11** (`integration`): `ruff check src/ tests/` and the full `pytest` suite pass on the existing CI matrix with no workflow changes and no new runtime dependencies. - [ ] **AC12** (`manual`): on Windows in Git Bash, a genuine absolute path containing backslashes is accepted by `--ref` and rejected by `--refs`, demonstrating the reported defect and its fix in the shell that produced the report. ## Testing Approach ### Validation steps 1. Assert the argv-level property directly: the same path that fails as `--refs` JSON succeeds as `--ref`. That is the whole claim, and it is testable without a Windows shell by using a path whose JSON escaping is invalid. 2. Cover the Windows case separately and mark it platform-specific rather than pretending a POSIX runner exercises it. 3. Build multi-problem sends deliberately, and assert both the count and that each individual message still appears in full. 4. Re-run with `AGENT_MAIL_RUNNER=binary` and `AGENT_MAIL_RUNNER=npm`. ### Test cases | Input | Expected | |---|---| | `--ref existing.md` | stored as an absolute path | | `--ref a --ref b --ref c` | three paths, in order | | `--ref x --refs '["y"]'` | error naming both flags | | `--ref missing.md` | error naming `--ref` and the resolved path | | `--refs` with an invalid JSON escape | JSON error — the reported defect, unchanged | | `--ref` with the same path | accepted | | over-long body + missing ref + bad `--to` | one response, three numbered problems | | unreadable `--body-file` | exactly one problem | | one problem only | today's message, unchanged | ## Out of Scope - Changing the 2000-character body cap or its error. The report explicitly asks that it be left alone, and spec 010's reasoning stands. - `--dry-run` on `send` — see Key Decisions. - Deprecating or removing `--refs`. - Reading, copying, or transmitting the contents of a referenced file (spec 001). - Aggregating validation on any command other than `send`. - Path canonicalisation rules, which spec 010 settled. - Shell-quoting help beyond what `describe` states. ## References - Issue: [#3 — Agent ergonomics: `--refs` JSON in argv breaks on Windows paths, and validation fails one error at a time](https://github.com/JuanjoFuchs/agent-mail-cli/issues/3). - spec 001: [`specs/001-agent-mail-cli.md`](001-agent-mail-cli.md) — the behavioral contract this spec amends. - spec 010: [`specs/010-messages-are-not-documents.md`](010-messages-are-not-documents.md) — made absolute refs mandatory, which is what turned this into a blocking defect. - Implementation: [`src/agent_mail/cli.py`](../src/agent_mail/cli.py).