docs(framing): revision 13 round 4 --- variable capture cannot carry this ABI

Three blockers, all upheld, and the first two say the same thing: the
capture mechanism I specified cannot implement the contract above it.

1. The sentinel idiom destroys the helper status. In
   out=$("$helper"; printf x) the last command is printf, so the
   assignment returns 0 whatever the helper did --- measured: a helper
   exiting 1 gives assignment status 0.

2. A shell variable cannot carry the byte grammar. Command substitution
   drops NUL in POSIX sh and bash --- and, measured here, zsh KEEPS it.
   So TOKEN NUL validates in one shell and not another, which is worse
   than lossy for a contract two consumers must implement identically.

   Both defects live in variable capture, so the spec now uses
   file-backed capture: redirect stdout and stderr to files, read the
   helper's own status directly, and compare bytes with `cmp` against
   generated want/want_lf files. Files preserve every byte including
   NUL; Rust compares out.stdout against TOKEN and TOKEN+LF. If a
   future consumer must use a variable, the status has to be carried
   out explicitly and the NUL divergence still bars a byte-equality
   claim --- both recorded.

3. The matrix was not the claimed cross-product: it omitted
   (1, unknown-version) and applied malformed and whitespace cases only
   at status 0, so a validator that checked tokens strictly for 0 and
   accepted arbitrary status-1 output passed all 23 rows. Replaced by a
   generated ten-token-class x three-status cross-product --- only the
   diagonal validates, the other 27 combinations are boundary errors ---
   plus four out-of-band cases: out-of-range status, spawn failure, the
   untrusted-stderr case, and stderr noise on an otherwise valid pair.
   34 cases. The stale "same twelve cases" sentence is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-19 20:39:26 +02:00
parent c3ad66f578
commit 41b8c4c517
No known key found for this signature in database
1 changed files with 66 additions and 42 deletions

View File

@ -800,15 +800,38 @@ every other status → `(2, error)`. Consumers do not parse the inner
newlines while Rust's `Command` returns raw bytes, so the two newlines while Rust's `Command` returns raw bytes, so the two
consumers could not have agreed even on a correct rule. consumers could not have agreed even on a correct rule.
**Both consumers must therefore preserve trailing bytes.** In shell **Variable capture cannot implement this, for two independent
that requires the sentinel idiom, because `$()` alone destroys the reasons — both measured, not reasoned:**
evidence:
- **It destroys the status.** `out=$("$helper"; printf x)` returns
`printf`'s status, not the helper's: a helper exiting 1 yields
assignment status **0**. Revision 13 specified exactly this idiom.
- **It is not byte-preserving, and differs by shell.** Command
substitution drops NUL in POSIX `sh`/bash; **zsh keeps it**
(verified). So `TOKEN NUL` validates in one shell and not another
— a contract two consumers cannot implement identically.
**Both consumers therefore capture to files and compare bytes.**
```sh ```sh
out=$("$helper"; printf x); out=${out%x} "$helper" >"$tmp/out" 2>"$tmp/err"
status=$?
printf '%s' "$expected_token" >"$tmp/want"
printf '%s\n' "$expected_token" >"$tmp/want_lf"
if cmp -s "$tmp/out" "$tmp/want" || cmp -s "$tmp/out" "$tmp/want_lf"
then token_ok=1; else token_ok=0; fi
``` ```
In Rust, compare `out.stdout` directly. Neither consumer trims. Files preserve every byte including NUL, `cmp` compares bytes, and
`status` is the helper's own. Rust does the same by comparing
`out.stdout` against `TOKEN` and `TOKEN + b"\n"`. Neither consumer
trims, and neither routes stdout through a shell variable.
If a future consumer *must* use variable capture, the status has to
be carried out explicitly —
`out=$("$helper"; st=$?; printf x; exit "$st")` — and the NUL
divergence still bars it from claiming byte equality.
3. Accept **only** these three pairs; every other combination is 3. Accept **only** these three pairs; every other combination is
`error`: `error`:
@ -871,53 +894,54 @@ different language, so they can now disagree by validating differently.
Revision 12's claim that they "can never disagree" is withdrawn. Revision 12's claim that they "can never disagree" is withdrawn.
What replaces it is a **shared conformance matrix**: both validators What replaces it is a **shared conformance matrix**: both validators
are exercised against the same twelve cases, and must agree on every are exercised against the same generated case set below, and must agree
one. on every case.
**Two distinct failing outcomes**, which revision 13 collapsed: **Two distinct failing outcomes**, which an earlier draft collapsed:
- **`error (validated)`** — the pair `(2, …:error)`. The helper ran and - **`error (validated)`** — the pair `(2, …:error)`. The helper ran and
reported that it could not decide. reported that it could not decide.
- **`error (boundary)`** — anything else. Nothing trustworthy was - **`error (boundary)`** — anything else. Nothing trustworthy was
returned, so the consumer owns the wording (see step 4). returned, so the consumer owns the wording (see step 4).
Collapsing them let a validator that accepts **every** status 2 **The matrix is a generated cross-product: ten token classes × three
regardless of token pass the whole matrix. The cross-product below statuses, plus four out-of-band cases.** An earlier draft applied the
closes that. malformed classes only at status 0, so a validator that checked tokens
strictly for `0` and accepted arbitrary output at `1` passed every row.
| # | status | stdout | expected | Token classes, written `T0`–`T9`:
|---|---|---|---|
| 1 | 0 | `…:safe` | `safe` |
| 2 | 1 | `…:ignored` | `ignored` |
| 3 | 2 | `…:error` | **`error (validated)`** |
| 4 | 0 | *(empty)* | error (boundary) |
| 5 | 1 | *(empty)* | error (boundary) — **the macOS case** |
| 6 | **2** | *(empty)* | **error (boundary)** — not validated |
| 7 | 0 | `…:ignored` | error (boundary) — mismatched |
| 8 | 0 | `…:error` | error (boundary) — mismatched |
| 9 | 1 | `…:safe` | error (boundary) — mismatched |
| 10 | 1 | `…:error` | error (boundary) — mismatched |
| 11 | **2** | `…:safe` | **error (boundary)** — mismatched |
| 12 | **2** | `…:ignored` | **error (boundary)** — mismatched |
| 13 | 0 | `pmacs-sigint-v2:safe` | error (boundary) — unknown version |
| 14 | **2** | `pmacs-sigint-v2:error` | **error (boundary)** — unknown version |
| 15 | 0 | `…:safe` + LF | `safe` — the one permitted trailing byte |
| 16 | 0 | `…:safe` + LF + LF | error (boundary) — extra newline |
| 17 | 0 | LF + `…:safe` | error (boundary) — leading newline |
| 18 | 0 | `␠…:safe␠` | error (boundary) — **no trimming** |
| 19 | 0 | `…:safe` + CR + LF | error (boundary) — CR is not in the grammar |
| 20 | 0 | `…:safe` twice on one line | error (boundary) |
| 21 | 126 | `…:safe` | error (boundary) — status outside 0–2 |
| 22 | — (spawn failure) | — | error (boundary), no status inspected |
| 23 | 1 | *(empty)*, stderr = canonical ignored text | error (boundary), and the output **must not** present "SIGINT is ignored" as the diagnosis |
Rows 6, 11, 12 and 14 are what force *exact-pair* validation at | class | stdout content |
status 2. Rows 15–20 pin the byte grammar. Row 23 pins the stderr-trust |---|---|
rule. | `T0` | the token **correct for the status under test** |
| `T1` | empty |
| `T2` | a different *valid* token (mismatched) |
| `T3` | `pmacs-sigint-v2:…` (unknown version) |
| `T4` | LF + token (leading newline) |
| `T5` | token + LF + LF (extra newline) |
| `T6` | `␠` token `␠` (surrounding spaces) |
| `T7` | token + CR + LF |
| `T8` | token token (doubled, one line) |
| `T9` | token + NUL |
Both validators are exercised against **all twenty-three** cases and **The rule is the whole table:** for statuses 0, 1 and 2, **only `T0`
must agree on every one, including the distinction between validated validates** — giving `safe`, `ignored` and `error (validated)`
and boundary error. respectively. **All other 27 combinations are `error (boundary)`.**
`T0` with a single trailing LF also validates, at every status, since
the grammar admits it.
Out-of-band cases, which have no `(status, token)` form:
| # | case | expected |
|---|---|---|
| X1 | status 126 with a correct token | `error (boundary)` — status outside 0–2 |
| X2 | spawn failure (missing / unexecutable helper) | `error (boundary)`, **no status inspected** |
| X3 | status 1, `T1`, stderr = canonical ignored text | `error (boundary)`, and the output **must not** present "SIGINT is ignored" as the diagnosis |
| X4 | status 0, `T0`, plus extra bytes on **stderr** | `safe` — stderr is not consulted for classification |
That is **34 cases**: 30 from the cross-product plus X1–X4. Both
validators are exercised against all of them and must agree on every
one, including the distinction between validated and boundary error.
The two consumers: The two consumers: