Skip to content

Commit f7d99a0

Browse files
docs: specify exact-generation exit evidence
1 parent 9c363de commit f7d99a0

3 files changed

Lines changed: 120 additions & 1 deletion

File tree

docs/vrs/requirements.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,11 @@ implementation contract and validation map live in [spec.md](./spec.md).
100100
remote routing preserve the applicable runtime, stream, geometry, registry,
101101
and lifecycle contracts. A surface rejects unsupported capabilities instead
102102
of silently weakening them; tests use real PTYs and processes.
103+
- **R12 Exact-generation retained exit evidence:** Supported client and CLI
104+
surfaces expose one bounded, tagged snapshot of retained terminal evidence
105+
for an exact stable id and opaque generation, distinguishing exited,
106+
vanished, live, missing, busy, unavailable, and invalid state. Conditional
107+
cleanup removes artifacts only while that same terminal generation remains;
108+
it never removes a live or replacement generation. Semantic outcomes and
109+
operational failures are machine-distinguishable, and validation covers the
110+
snapshot-to-cleanup race with real processes.

docs/vrs/spec.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,114 @@ Explicit lifecycle commands and `gc` own mutation. Cleanup is authorized by the
174174
observed generation; removal wins over late daemon finalization, and permanent
175175
respawn cannot overwrite a replacement (R03, R10).
176176

177+
### Retained exit evidence
178+
179+
An evidence consumer separates observation from cleanup (R12):
180+
181+
```text
182+
exact stable id
183+
|
184+
v
185+
bounded snapshot -- opaque generation --> durable consumer action
186+
|
187+
v
188+
conditional same-generation remove
189+
|
190+
+-------------------+--------------------+
191+
| |
192+
removed mismatch/live/failure
193+
| |
194+
artifacts absent artifacts preserved
195+
```
196+
197+
The exported client API and machine CLI share these tagged result schemas:
198+
199+
```typescript
200+
type ExitEvidenceTail =
201+
| { _tag: "present"; lastLines: string[] }
202+
| { _tag: "unavailable" }
203+
204+
type ExitEvidenceResult =
205+
| {
206+
_tag: "snapshot"
207+
snapshot: {
208+
name: string
209+
generation: string
210+
status: "exited" | "vanished"
211+
exitCode: number | null
212+
stream: "combined"
213+
tail: ExitEvidenceTail
214+
}
215+
}
216+
| {
217+
_tag: "unavailable"
218+
reason:
219+
| "missing"
220+
| "running"
221+
| "busy"
222+
| "generation-unavailable"
223+
| "invalid-metadata"
224+
}
225+
226+
type RemoveGenerationResult =
227+
| { _tag: "removed" }
228+
| { _tag: "missing" }
229+
| { _tag: "generation-mismatch" }
230+
| { _tag: "not-terminal" }
231+
| { _tag: "invalid-metadata" }
232+
| { _tag: "busy" }
233+
```
234+
235+
Snapshot and remove accept an exact filename-safe stable id, never a mutable
236+
display-name reference. The generation is an opaque, nonempty token returned by
237+
the snapshot; callers compare or return it without interpreting its format.
238+
`exited` carries the recorded integer exit code, while `vanished` has a null
239+
exit code because no terminal exit record exists. `tail.present` is the exact
240+
persisted combined-stream line array, including an empty array;
241+
`tail.unavailable` means no retained tail was persisted.
242+
243+
The evidence metadata reader opens the metadata path read-only, nonblocking,
244+
and without following symlinks. It requires a regular file no larger than 1
245+
MiB and a JSON object with these structural constraints:
246+
247+
| Field | Constraint |
248+
| --- | --- |
249+
| `generation` | required nonempty string |
250+
| `daemonPid` | optional positive integer |
251+
| `exitedAt`, `exitCode` | both absent, or nonempty string plus integer |
252+
| `lastLines` | optional array of at most 200 strings |
253+
254+
Unknown compatible fields are ignored. Malformed JSON, oversized or nonregular
255+
artifacts, symlinks, invalid field types, mixed terminal fields, and over-bound
256+
tails produce `invalid-metadata`; they are neither truncated nor treated as
257+
missing. A legacy record without `generation` is explicitly
258+
`generation-unavailable` for snapshot and `generation-mismatch` for removal.
259+
260+
Snapshot holds the stable-id creation lock across metadata validation and the
261+
daemon-generation liveness check. Conditional removal serializes event and
262+
creation mutation, then applies this order:
263+
264+
1. read and structurally validate metadata;
265+
2. compare the retained opaque generation with the expected generation;
266+
3. refuse a live matching generation;
267+
4. re-read, revalidate, and compare the generation;
268+
5. remove socket, pid, event, and recovery-revision artifacts;
269+
6. remove metadata last, preserving evidence if an earlier cleanup step fails;
270+
7. release both locks.
271+
272+
Missing artifacts are idempotent. Contention and semantic refusal return tagged
273+
results. Argument, metadata-cleanup I/O, and output-transport failures remain
274+
operational errors rather than success-shaped results. The CLI output law is:
275+
276+
| CLI outcome | stdout | stderr | exit status |
277+
| --- | --- | --- | --- |
278+
| semantic snapshot/remove result | exactly one tagged JSON document | empty | 0 |
279+
| argument or operational failure | no success JSON | diagnostic | nonzero |
280+
281+
`evidence snapshot --id` and `evidence remove --id --expected-generation`
282+
provide leaf-specific help and completion schemas; snapshot never advertises
283+
the remove-only generation option (R11, R12).
284+
177285
### Live registry recovery
178286
179287
A supporting daemon may publish an opaque recovery capability only when it can
@@ -220,6 +328,7 @@ input, resize, and multi-client geometry without mocks.
220328
| R09 | [sessions](../../src/sessions.ts), [server](../../src/server.ts), [recovery](../../src/recovery.ts), [CLI](../../src/cli.ts) | [root](../../tests/pty-root.test.ts), [display name](../../tests/display-name.test.ts), [status](../../tests/stats-cli.test.ts), [list purity](../../tests/list-purity.test.ts), [recovery](../../tests/recovery.test.ts) |
221329
| R10 | [sessions](../../src/sessions.ts), [events](../../src/events.ts), [recovery](../../src/recovery.ts), [protocol](../../src/protocol.ts) | [atomic writes](../../tests/atomic-writes.test.ts), [metadata events](../../tests/metadata-events.test.ts), [events](../../tests/events.test.ts), [recovery](../../tests/recovery.test.ts), [disk layout](../../tests/disk-layout-docs.test.ts) |
222330
| R11 | [CLI](../../src/cli.ts), [client API](../../src/client-api.ts), [remote](../../src/remote.ts), [testing API](../../src/testing/index.ts) | [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [remote](../../tests/remote-fabric.test.ts), [screenshots](../../tests/screenshot.test.ts), [keys](../../tests/keys.test.ts) |
331+
| R12 | [sessions](../../src/sessions.ts), [server](../../src/server.ts), [client API](../../src/client-api.ts), [CLI](../../src/cli.ts), [completions](../../src/completions.ts) | [exit evidence](../../tests/exit-reap.test.ts), [generation guard](../../tests/gc-generation-guard.test.ts), [immediate reuse](../../tests/rm-immediate-reuse.test.ts), [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [security](../../tests/security-fixes.test.ts) |
223332
224333
`node scripts/verify-docs.ts --vrs-only` validates this two-document shape,
225334
sequential requirement IDs, links, and complete requirement references.

scripts/verify-docs.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function verifyVrs(): void {
2020
const requirementsPath = path.join(vrsRoot, "requirements.md");
2121
const specPath = path.join(vrsRoot, "spec.md");
2222
const errors: string[] = [];
23+
let requirementCount = 0;
2324

2425
if (actual.join("\n") !== expected.join("\n")) {
2526
errors.push(`docs/vrs must contain only ${expected.join(" and ")}`);
@@ -32,6 +33,7 @@ function verifyVrs(): void {
3233
const ids = [...requirements.matchAll(/^- \*\*(R\d{2}) [^*]+:\*\*/gm)].map(
3334
(match) => match[1],
3435
);
36+
requirementCount = ids.length;
3537

3638
if (ids.length === 0) errors.push("requirements.md defines no requirement IDs");
3739
if (!ids.every((id, index) => id === `R${String(index + 1).padStart(2, "0")}`)) {
@@ -67,7 +69,7 @@ function verifyVrs(): void {
6769
console.error(`VRS verification failed:\n${errors.map((error) => `- ${error}`).join("\n")}`);
6870
process.exit(1);
6971
}
70-
console.log("Verified 2 VRS documents and 11 requirement IDs");
72+
console.log(`Verified 2 VRS documents and ${requirementCount} requirement IDs`);
7173
}
7274

7375
verifyVrs();

0 commit comments

Comments
 (0)