Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changelog/next/added-supersedes-link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
`--supersedes <id>` on `gate request` / `gate fast-track` — a
forward-only link to an older request this one corrects.

Principle 04 has stated the shape since it was written ("a correction is
a new record that references the old"), and the `ctx` passage has
shipped `ctx supersede` for a while. `gate` never had it, so corrections
lived in `action` prose: measured against a 2213-record content_root,
67 records (3.0%) name an older id in prose that no reader can traverse
mechanically. That is principle 17 — a restatement bound to nothing.

The old record is never mutated; both stay in the ledger. A target that
does not exist is refused at the write boundary, before any id is
allocated, so a bad link never leaves a half-written record behind.

Deliberately absent: an inverse `superseded_by` field (it would mutate
an immutable record) and any taxonomy of correction kinds (`--reason`
carries which of the four shapes applies). `gate show <old-id>` stays
silent about corrections that point at it — closing that costs a full
scan, 0.11s to 0.91s on the measured root, and belongs on `gate chain`.
2 changes: 1 addition & 1 deletion AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ gate execute <id> --by <m> [--cwd <path>] # cwd stamped on the
gate complete <id> --by <m> [--note "..."] [--cliff "<hint for next agent>"]
gate fail <id> --by <m> --reason "..."
gate fast-track --from <m> --action "..." --reason "..." \
[--executors a[,b,c]] [--with ...]
[--executors a[,b,c]] [--with ...] [--supersedes <id>]
gate thank <to> --for <id> [--by <m>] [--reason <s>] # gratitude (no verdict, no calibration)
```

Expand Down
1 change: 1 addition & 0 deletions docs/plugin-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ primitives. The table below tells you which.
| `request.depth` | `'standard'\|'deep' \| undefined` | `'deep'` or undef |
| `request.promotedFrom`| `string \| undefined` | issue id like `'i-2026-04-15-0007'` or undef |
| `request.sourceAgoraPlay` | `string \| undefined` | play id or undef |
| `request.supersedes` | `string \| undefined` | id of an older request this one corrects, or undef |
| `request.template` | `string \| undefined` | template name or undef |
| `request.templateVersion` | `number \| undefined` | `1` or undef |
| `request.gateRequiredAcknowledged` | `boolean \| undefined` | true/false/undef |
Expand Down
56 changes: 56 additions & 0 deletions docs/verbs.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,62 @@ entries (`pending`, `approved`, `executing`, `completed`) with
distinguish them from full-cycle transitions. `--auto-review` still
works — it just moves the review from "blocking" to "after-the-fact."

### Corrections: `--supersedes`

`--supersedes <id>` on `gate request` / `gate fast-track` records a
forward link to an older request this one corrects.

```bash
gate fast-track --from eris --supersedes 2026-04-15-0007 \
--action "the ratio was wrong" --reason "re-measured: 67, not 407"
```

The old record is **never mutated**. Both stay in the ledger, and the
supersession is reconstructable from the link alone — principle 04:
*a correction is a new record that references the old, which preserves
the trail of thinking, not just the latest conclusion.*

Refused when the target does not exist. A dangling correction link is
worse than no link: a reader who follows one cannot distinguish a
mistyped id from a deleted record, and whatever gets written outlives
the writer who could have explained it.

**Why a flag and not a `gate supersede` verb.** The sibling `ctx`
passage ships `ctx supersede <old-id>` as its own verb, because ctx has
exactly one record-creating shape. `gate` has two entry verbs
(`request` for the full lifecycle, `fast-track` for the one-shot), so a
correction-only verb would have to pick one and leave the other unable
to correct anything.

**What it does not do.**

- There is no inverse `superseded_by` field on the old record. Writing
one would mutate a record that is meant to be immutable. Readers
derive the inverse by scanning for records whose `supersedes` names a
given id.
- Consequently `gate show <old-id>` does **not** announce that a
correction exists. Rendering it would cost a full scan: measured
2026-09-03 against one 2213-record content_root (the THS guild, outside
this repository — an unbound figure, cited for its order of magnitude,
not as a property of guild-cli), `gate show` runs in
0.11s and a full scan in 0.91s — 8x on the hottest read verb. The gap
is named rather than paid for (principle 03: label what is silenced).

`gate chain <old-id>` **does** surface it, under "referenced by
requests" — chain already scans every record, so the link costs
nothing extra there. The structured field is fed back through the
same id-scanner as prose (see `gatherRequestText`); without that,
moving the mention out of `action` text would have made chain blind
to the very links this flag exists to create.
- It carries no taxonomy. Corrections in practice come in at least four
shapes — the claim was wrong; the *grounds* were wrong but the
judgment stands; the record was merely incomplete; the judgment is
withdrawn. One link plus a `--reason` covers all four; splitting the
flag would put a vocabulary choice in front of every use, and the
measured population is small enough (67 of 2213 records, 3.0%, in that
same content_root) that
the friction would likely cost more than the precision buys.

### Pair-mode: who were you with?

`--with <n1>[,<n2>...]` on `gate request` / `gate fast-track`
Expand Down
33 changes: 33 additions & 0 deletions src/application/request/RequestUseCases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ function dateKey(d: Date): string {
return `${y}-${m}-${day}`;
}

/**
* Thrown when `--supersedes` names a request that does not exist.
*
* A correction must point at something real: a dangling link is worse
* than no link, because a reader who follows it cannot distinguish
* "the record was deleted" from "the id was mistyped". Rejected loudly
* at the write boundary rather than persisted (principle 04 —
* records-outlive-writers means a written link is forever).
*/
export class SupersedeTargetMissing extends Error {
constructor(public readonly id: string) {
super(`supersede target not found: ${id}`);
this.name = 'SupersedeTargetMissing';
}
}

export class RequestUseCases {
constructor(private readonly deps: RequestUseCasesDeps) {}

Expand Down Expand Up @@ -67,6 +83,11 @@ export class RequestUseCases {
* derived from an agora play's invitation/cliff. Persisted only
* when set (absence is the common case). */
sourceAgoraPlay?: string;
/** Id of an older request this one corrects (`--supersedes`).
* Unlike promotedFrom / sourceAgoraPlay this is operator-typed,
* so the target's *existence* is verified here before any record
* is allocated. Throws SupersedeTargetMissing when absent. */
supersedes?: string;
/** Wave-brief template stamp (#235). Threads through from the
* interface layer's `--template` flag. The trio (template name,
* version, gate_required acknowledgement) moves together; if the
Expand Down Expand Up @@ -100,6 +121,16 @@ export class RequestUseCases {
}
}

// Verify the supersede target BEFORE allocating an id. Order is
// load-bearing: validating after saveNew would leave a real record
// behind on a bad link, and the caller's error would then be a lie
// about what the substrate contains.
if (input.supersedes !== undefined) {
const targetId = RequestId.of(input.supersedes);
const target = await requests.findById(targetId);
if (target === null) throw new SupersedeTargetMissing(targetId.value);
}

// Sequence allocation + create is TOCTOU: two concurrent calls may
// race to the same number. saveNew uses an O_EXCL create under the
// hood; on RequestIdCollision we bump the sequence and retry.
Expand Down Expand Up @@ -129,6 +160,8 @@ export class RequestUseCases {
createArgs.requiresWorktreeIsolation = true;
if (input.sourceAgoraPlay !== undefined)
createArgs.sourceAgoraPlay = input.sourceAgoraPlay;
if (input.supersedes !== undefined)
createArgs.supersedes = input.supersedes;
if (input.template !== undefined) {
createArgs.template = input.template;
if (input.templateVersion !== undefined)
Expand Down
65 changes: 65 additions & 0 deletions src/domain/request/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,33 @@ export interface RequestProps {
* is bonus.
*/
sourceAgoraPlay?: string;
/**
* Operator-supplied structured link to an older request this one
* corrects (via `gate request --supersedes` / `gate fast-track
* --supersedes`). Forward-only: the *correcting* record carries the
* link and the superseded record is never mutated, so the ledger
* keeps both and the supersession is reconstructable from the link
* alone (principle 04 — "a correction is a new record that
* references the old").
*
* Distinct from `promotedFrom` / `sourceAgoraPlay` in one way that
* matters: those are tool-generated, this one is *typed by the
* operator*. It therefore appears in KNOWN_FLAGS and must appear in
* `schema.input.properties` (principle 10).
*
* Why structured rather than prose: correcting requests already name
* the old id in `action` prose today (67 of 2213 records in the THS
* content_root, measured 2026-09-03). A prose mention is a
* restatement no reader can traverse mechanically — principle 17
* asks that it be bound to structure instead.
*
* Deliberately NOT the inverse direction: there is no
* `superseded_by` field on the old record, because writing one would
* mutate a record that is supposed to be immutable. Readers derive
* the inverse by scanning for requests whose `supersedes` names a
* given id.
*/
supersedes?: string;
/**
* Worktree-isolation requirement (issue #231). When true, parallel
* `gate execute` invocations on the same target from the SAME
Expand Down Expand Up @@ -493,6 +520,12 @@ export class Request {
* was bridged from (via `gate request --from-agora <play_id>`).
* Populated by the --from-agora orchestration only. Issue #232. */
sourceAgoraPlay?: string;
/** See RequestProps.supersedes — id of an older request this one
* corrects. Operator-supplied via `--supersedes`. Rejected at
* create time when it names this request's own id (a record
* cannot correct itself); target *existence* is an application
* concern (the domain holds no repository). */
supersedes?: string;
/** See RequestProps.requiresWorktreeIsolation — set by the
* interface layer when profile=swarm + executors.length > 1.
* Persisted as `requires_worktree_isolation: true` only when
Expand Down Expand Up @@ -603,6 +636,27 @@ export class Request {
// line of defence.
props.sourceAgoraPlay = sanitizeText(input.sourceAgoraPlay, 'sourceAgoraPlay');
}
if (input.supersedes !== undefined) {
// RequestId.of validates the shape (and accepts the legacy
// 3-digit form, so a correction can point at a pre-0.2.0
// record — principle 04: cold readers of old YAML keep working).
const target = RequestId.of(input.supersedes).value;
// Self-supersession guard. NOTE (measured 2026-09-03): this branch
// is unreachable from the CLI — the use case verifies the target
// exists before allocating this id, and a not-yet-written id never
// resolves, so a self-referencing --supersedes always fails earlier
// with "target not found". The guard is kept for non-CLI callers
// (tests, plugins, a future MCP surface) that construct a Request
// directly with an id in hand. Its red is reachable from the domain
// suite, so it is a guard and not decoration.
if (target === input.id.value) {
throw new DomainError(
'a request cannot supersede itself',
'supersedes',
);
}
props.supersedes = target;
}
// Worktree-isolation: persist only when explicitly true. The
// false case is represented by field absence on disk so the YAML
// surface stays minimal (matches `depth`, `with`, `target` etc).
Expand Down Expand Up @@ -747,6 +801,13 @@ export class Request {
get sourceAgoraPlay(): string | undefined {
return this.props.sourceAgoraPlay;
}
/** Id of an older request this one corrects (`--supersedes`).
* Undefined for the ordinary case. The inverse direction is not
* stored — a reader derives "superseded by" by scanning for
* requests whose `supersedes` names a given id. */
get supersedes(): string | undefined {
return this.props.supersedes;
}
get with(): readonly MemberName[] {
return this.props.with ?? [];
}
Expand Down Expand Up @@ -1645,6 +1706,10 @@ export class Request {
// hydrate "absent ⇒ undefined" branch below.
if (this.props.sourceAgoraPlay !== undefined)
out['source_agora_play'] = this.props.sourceAgoraPlay;
// supersedes — omit when absent so every non-correcting record
// (2146 of 2213 in the THS content_root) round-trips byte-identical.
if (this.props.supersedes !== undefined)
out['supersedes'] = this.props.supersedes;
// Session_id stamped at request creation (issue #249). Surface
// only when set — pre-#249 records and same-body single-session
// requests both emit byte-identical YAML on round-trip. Slice 1
Expand Down
8 changes: 8 additions & 0 deletions src/infrastructure/persistence/YamlRequestRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,14 @@ function hydrate(
if (typeof obj['source_agora_play'] === 'string') {
props.sourceAgoraPlay = obj['source_agora_play'] as string;
}
// supersedes. Same permissive read as the two links above: a
// string is trusted as-is (shape was validated at the write
// boundary by RequestId.of), anything else is ignored rather than
// rejected. Principle 04 — a malformed field must not make an
// otherwise-readable record unreadable to a cold reader.
if (typeof obj['supersedes'] === 'string') {
props.supersedes = obj['supersedes'] as string;
}
// opened_by_session (#249). Tolerated as a non-empty string. Same
// permissive read pattern as source_agora_play / claim_note —
// we never reject, only ignore malformed values. Format
Expand Down
16 changes: 13 additions & 3 deletions src/interface/gate/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,29 @@ export {
} from '../../domain/shared/extractReferences.js';

/**
* Collect every piece of searchable free text belonging to a request,
* so extractReferences can see cross-references buried in reviews
* and closure notes as well as the action/reason headers.
* Collect every piece of searchable text belonging to a request, so
* extractReferences can see cross-references buried in reviews and
* closure notes as well as the action/reason headers.
*
* `supersedes` is included even though it is a structured field rather
* than prose. Corrections used to name the older id in `action` text,
* which chain picked up for free; moving that mention into a field
* would otherwise make chain blind to exactly the links the field was
* added to make traversable — the structure would be more precise and
* less reachable at the same time. Feeding it back through the same
* scanner keeps one traversal path instead of two.
*/
export function gatherRequestText(r: {
action: string;
reason: string;
completion_note?: string;
deny_reason?: string;
failure_reason?: string;
supersedes?: string;
reviews?: ReadonlyArray<{ comment: string }>;
}): string {
const parts: string[] = [r.action, r.reason];
if (r.supersedes) parts.push(r.supersedes);
if (r.completion_note) parts.push(r.completion_note);
if (r.deny_reason) parts.push(r.deny_reason);
if (r.failure_reason) parts.push(r.failure_reason);
Expand Down
2 changes: 2 additions & 0 deletions src/interface/gate/handlers/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@ export async function reqChain(c: C, args: ParsedArgs): Promise<number> {
...(j.failure_reason !== undefined
? { failure_reason: j.failure_reason }
: {}),
...(j.supersedes !== undefined ? { supersedes: j.supersedes } : {}),
...(j.reviews !== undefined
? { reviews: j.reviews.map((r) => ({ comment: r.comment })) }
: {}),
Expand Down Expand Up @@ -602,6 +603,7 @@ export async function reqChain(c: C, args: ParsedArgs): Promise<number> {
...(rj.failure_reason !== undefined
? { failure_reason: rj.failure_reason }
: {}),
...(rj.supersedes !== undefined ? { supersedes: rj.supersedes } : {}),
...(rj.reviews !== undefined
? { reviews: rj.reviews.map((rv) => ({ comment: rv.comment })) }
: {}),
Expand Down
Loading
Loading