diff --git a/.changelog/next/added-supersedes-link.md b/.changelog/next/added-supersedes-link.md new file mode 100644 index 00000000..2b841c8a --- /dev/null +++ b/.changelog/next/added-supersedes-link.md @@ -0,0 +1,19 @@ +`--supersedes ` 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 ` 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`. diff --git a/AGENT.md b/AGENT.md index aba3be4f..b528c2f7 100644 --- a/AGENT.md +++ b/AGENT.md @@ -146,7 +146,7 @@ gate execute --by [--cwd ] # cwd stamped on the gate complete --by [--note "..."] [--cliff ""] gate fail --by --reason "..." gate fast-track --from --action "..." --reason "..." \ - [--executors a[,b,c]] [--with ...] + [--executors a[,b,c]] [--with ...] [--supersedes ] gate thank --for [--by ] [--reason ] # gratitude (no verdict, no calibration) ``` diff --git a/docs/plugin-schema.md b/docs/plugin-schema.md index 4cb5c17e..87fdfc59 100644 --- a/docs/plugin-schema.md +++ b/docs/plugin-schema.md @@ -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 | diff --git a/docs/verbs.md b/docs/verbs.md index 0b23a858..c75c817a 100644 --- a/docs/verbs.md +++ b/docs/verbs.md @@ -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 ` 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 ` 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 ` 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 ` **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 [,...]` on `gate request` / `gate fast-track` diff --git a/src/application/request/RequestUseCases.ts b/src/application/request/RequestUseCases.ts index 53081df1..3311715b 100644 --- a/src/application/request/RequestUseCases.ts +++ b/src/application/request/RequestUseCases.ts @@ -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) {} @@ -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 @@ -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. @@ -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) diff --git a/src/domain/request/Request.ts b/src/domain/request/Request.ts index 194e8e43..8d06a448 100644 --- a/src/domain/request/Request.ts +++ b/src/domain/request/Request.ts @@ -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 @@ -493,6 +520,12 @@ export class Request { * was bridged from (via `gate request --from-agora `). * 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 @@ -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). @@ -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 ?? []; } @@ -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 diff --git a/src/infrastructure/persistence/YamlRequestRepository.ts b/src/infrastructure/persistence/YamlRequestRepository.ts index 44864e91..f72106db 100644 --- a/src/infrastructure/persistence/YamlRequestRepository.ts +++ b/src/infrastructure/persistence/YamlRequestRepository.ts @@ -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 diff --git a/src/interface/gate/chain.ts b/src/interface/gate/chain.ts index 0fcc550f..057c0019 100644 --- a/src/interface/gate/chain.ts +++ b/src/interface/gate/chain.ts @@ -24,9 +24,17 @@ 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; @@ -34,9 +42,11 @@ export function gatherRequestText(r: { 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); diff --git a/src/interface/gate/handlers/read.ts b/src/interface/gate/handlers/read.ts index b6d69e2d..7bd03791 100644 --- a/src/interface/gate/handlers/read.ts +++ b/src/interface/gate/handlers/read.ts @@ -547,6 +547,7 @@ export async function reqChain(c: C, args: ParsedArgs): Promise { ...(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 })) } : {}), @@ -602,6 +603,7 @@ export async function reqChain(c: C, args: ParsedArgs): Promise { ...(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 })) } : {}), diff --git a/src/interface/gate/handlers/request.ts b/src/interface/gate/handlers/request.ts index e3364d67..189b80f2 100644 --- a/src/interface/gate/handlers/request.ts +++ b/src/interface/gate/handlers/request.ts @@ -37,6 +37,8 @@ import { import { emitWriteResponse } from './writeFormat.js'; import { parseFormat } from '../../shared/parseFormat.js'; import { renderVoice } from '../../shared/voiceRender.js'; +import { RecoverableError } from '../../shared/errorEnvelope.js'; +import { SupersedeTargetMissing } from '../../../application/request/RequestUseCases.js'; // Known flags per write-verb. Silent-ignore of unknown flags (e.g. // `--executr noir` instead of `--executor noir`) would let a typo @@ -66,6 +68,7 @@ const REQUEST_CREATE_KNOWN_FLAGS: ReadonlySet = new Set([ // schemaInputDriftDetector.test.ts.) 'from-agora', 'game', + 'supersedes', // #235 wave-brief template registry: --template name expands a brief // skeleton; explicit --action and --reason override the defaults. // Mutually exclusive with --from-agora (both supply action/reason @@ -81,8 +84,35 @@ const FAST_TRACK_KNOWN_FLAGS: ReadonlySet = new Set([ 'note', 'with', 'format', + 'supersedes', ]); + +/** + * Translate a missing supersede target into the recoverable envelope. + * + * A dangling correction link is refused at the write boundary rather + * than persisted: a reader who follows one cannot tell a mistyped id + * from a deleted record, and principle 04 means whatever gets written + * outlives the writer who could have explained it. + */ +function rethrowSupersedeTargetMissing(e: unknown): never { + if (e instanceof SupersedeTargetMissing) { + throw new RecoverableError( + `supersede target ${e.id} not found — a correction must point at a ` + + `real request.\n` + + ` gate list --state all`, + { + verb: 'list', + args: {}, + reason: `list the recorded requests to find the id being corrected.`, + }, + 'not_found', + ); + } + throw e; +} + export async function reqCreate(c: C, args: ParsedArgs): Promise { rejectUnknownFlags(args, REQUEST_CREATE_KNOWN_FLAGS, 'request'); const from = normalizeActor( @@ -288,6 +318,11 @@ export async function reqCreate(c: C, args: ParsedArgs): Promise { if (parsed.list.length > 0) input.executors = parsed.list; } if (target !== undefined) input.target = target; + // supersedes: id of an older request this one corrects. Existence is + // verified in the use case before any id is allocated, so a bad link + // never leaves a half-written record behind. + const supersedes = optionalOption(args, 'supersedes'); + if (supersedes !== undefined) input.supersedes = supersedes; // Worktree-isolation gating (#231). Two effects, both keyed on // "is this a parallel wave?" (executors.length > 1): // - profile=swarm → stamp `requires_worktree_isolation: true` @@ -361,7 +396,12 @@ export async function reqCreate(c: C, args: ParsedArgs): Promise { // round-trip byte-identical YAML. const sessionId = resolveGuildSessionId(); if (sessionId !== undefined) input.openedBySession = sessionId; - const r = await c.requestUC.create(input); + let r; + try { + r = await c.requestUC.create(input); + } catch (e) { + rethrowSupersedeTargetMissing(e); + } if (invokedBy !== undefined) { emitInvokedByNotice(from, invokedBy, 'request', r.id.value); } @@ -502,6 +542,8 @@ export async function reqFastTrack(c: C, args: ParsedArgs): Promise { }; if (autoReview !== undefined) createInput.autoReview = autoReview; if (withPartners.length > 0) createInput.with = withPartners; + const ftSupersedes = optionalOption(args, 'supersedes'); + if (ftSupersedes !== undefined) createInput.supersedes = ftSupersedes; // Same env-driven session stamp as `gate request` — fast-track is a // single user-facing verb that compresses request → approve → // execute → complete, but the create step is the legitimate carrier @@ -509,7 +551,12 @@ export async function reqFastTrack(c: C, args: ParsedArgs): Promise { const fastTrackSessionId = resolveGuildSessionId(); if (fastTrackSessionId !== undefined) createInput.openedBySession = fastTrackSessionId; - const created = await c.requestUC.create(createInput); + let created; + try { + created = await c.requestUC.create(createInput); + } catch (e) { + rethrowSupersedeTargetMissing(e); + } const id = created.id.value; // Fast-track is one user-facing command even though it executes diff --git a/src/interface/gate/handlers/requestReads.ts b/src/interface/gate/handlers/requestReads.ts index 816d0d17..ab64dd5c 100644 --- a/src/interface/gate/handlers/requestReads.ts +++ b/src/interface/gate/handlers/requestReads.ts @@ -323,6 +323,22 @@ function formatRequestText(r: Request): string { if (Array.isArray(j['with']) && j['with'].length > 0) { lines.push(` with: ${(j['with'] as string[]).join(', ')}`); } + // supersedes — rendered ABOVE the promoted_from / source_agora_play + // cluster on purpose. Those two answer "where did this come from"; + // this one answers "what does this overturn", which a reader needs + // before they read the action prose, not after. + // + // The inverse direction is deliberately NOT rendered here. Showing + // "superseded by " on the old record would require scanning + // every request: measured 2026-09-03 against the THS content_root + // (2213 records), `gate show` costs 0.11s and a full scan 0.91s — + // 8x on the hottest read verb. So the gap is named rather than + // paid for (principle 03: label what is silenced): a reader who + // opens a superseded record directly still sees nothing. Closing it + // belongs on `gate chain`, which already walks multiple records. + if (j['supersedes']) { + lines.push(` supersedes: ${j['supersedes']}`); + } if (j['promoted_from']) { lines.push(` promoted_from: ${j['promoted_from']}`); } diff --git a/src/interface/gate/handlers/schema.ts b/src/interface/gate/handlers/schema.ts index 08079909..8a960716 100644 --- a/src/interface/gate/handlers/schema.ts +++ b/src/interface/gate/handlers/schema.ts @@ -1697,6 +1697,18 @@ const VERBS: readonly VerbSchema[] = [ 'gate_required_acknowledged). Use `gate templates list` to ' + 'see the catalogue.', ), + supersedes: strOpt( + 'id of an older request this one corrects. Records a ' + + 'forward-only link (supersedes) on the NEW record; the ' + + 'superseded record is never mutated, so the ledger keeps ' + + 'both and a reader can reconstruct the correction from the ' + + 'link alone (principle 04). Refused when the target does ' + + 'not exist or names this request itself. There is no ' + + 'inverse superseded_by field: readers derive it by scanning ' + + 'for records whose supersedes names a given id. Use it when ' + + 'the new record corrects, amends, or withdraws an earlier ' + + 'one - the reason field carries which of the three.', + ), }, // action/reason are conditionally required: required unless one // of `--from-agora` (#232) or `--template` (#235) is supplied, @@ -2067,6 +2079,18 @@ const VERBS: readonly VerbSchema[] = [ with: strOpt('comma-separated dialogue partners (pair-mode)'), note: str, format: formatField, + supersedes: strOpt( + 'id of an older request this one corrects. Records a ' + + 'forward-only link (supersedes) on the NEW record; the ' + + 'superseded record is never mutated, so the ledger keeps ' + + 'both and a reader can reconstruct the correction from the ' + + 'link alone (principle 04). Refused when the target does ' + + 'not exist or names this request itself. There is no ' + + 'inverse superseded_by field: readers derive it by scanning ' + + 'for records whose supersedes names a given id. Use it when ' + + 'the new record corrects, amends, or withdraws an earlier ' + + 'one - the reason field carries which of the three.', + ), }, required: ['action', 'reason'], }, diff --git a/src/interface/gate/help.ts b/src/interface/gate/help.ts index c8cc2efa..21d23cb0 100644 --- a/src/interface/gate/help.ts +++ b/src/interface/gate/help.ts @@ -85,7 +85,14 @@ const SECTIONS: readonly Section[] = [ text: ' gate request --from --action --reason \n' + ' [--executors a[,b,...]] [--target ] [--auto-review ]\n' + - ' [--with [,...]] [--depth shallow|standard|deep]', + ' [--with [,...]] [--depth shallow|standard|deep]\n' + + ' [--supersedes ]\n' + + ' --supersedes records a forward link to an\n' + + ' older request this one corrects. The old\n' + + ' record is never mutated; both stay in the\n' + + ' ledger and the correction is traversable\n' + + ' from the link. Refused if the target does\n' + + ' not exist.', }, { tier: 'extra', @@ -196,7 +203,8 @@ const SECTIONS: readonly Section[] = [ text: ' gate fast-track --from --action --reason \n' + ' [--executors a[,b,...]] [--auto-review ] [--note ]\n' + - ' [--with [,...]]', + ' [--with [,...]] [--supersedes ]\n' + + ' --supersedes: see gate request above.', }, ], }, diff --git a/src/interface/gate/voices.ts b/src/interface/gate/voices.ts index 8705cb81..2ac4774f 100644 --- a/src/interface/gate/voices.ts +++ b/src/interface/gate/voices.ts @@ -55,6 +55,7 @@ export type RequestJSON = { // request came from `gate issues promote`. Used by chain as a // text-independent forward/inbound ref path. readonly promoted_from?: string; + readonly supersedes?: string; }; export type ReviewJSON = { diff --git a/tests/domain/Request.test.ts b/tests/domain/Request.test.ts index 613b7eee..88c55c59 100644 --- a/tests/domain/Request.test.ts +++ b/tests/domain/Request.test.ts @@ -724,3 +724,93 @@ test('Request.restore preserves promotedFrom on round-trip', () => { assert.equal(r.toJSON()['promoted_from'], 'i-2026-04-14-0007'); }); +// ── supersedes: forward link to the older request this one corrects ── +// +// Unlike promoted_from / source_agora_play these are operator-typed, +// so the domain owns two refusals: shape, and self-reference. + +test('Request.create with supersedes stores the id on the aggregate', () => { + const r = Request.create({ + id: RequestId.generate(d, 2), + from: 'alice', + action: 'correcting the earlier claim', + reason: 'the measurement was wrong', + supersedes: '2026-04-14-0001', + }); + assert.equal(r.supersedes, '2026-04-14-0001'); + assert.equal(r.toJSON()['supersedes'], '2026-04-14-0001'); +}); + +test('Request.toJSON omits supersedes when not set (ordinary records byte-identical)', () => { + const r = Request.create({ + id: RequestId.generate(d, 1), + from: 'alice', + action: 'a', + reason: 'r', + }); + assert.equal('supersedes' in r.toJSON(), false); +}); + +test('Request.create refuses a self-supersession', () => { + // A record cannot correct itself: the link would be a cycle of one + // and a reader following it would never reach a prior claim. + const id = RequestId.generate(d, 3); + assert.throws( + () => + Request.create({ + id, + from: 'alice', + action: 'a', + reason: 'r', + supersedes: id.value, + }), + /cannot supersede itself/, + ); +}); + +test('Request.create refuses a malformed supersedes id', () => { + assert.throws( + () => + Request.create({ + id: RequestId.generate(d, 4), + from: 'alice', + action: 'a', + reason: 'r', + supersedes: 'not-an-id', + }), + /Invalid request id/, + ); +}); + +test('Request.create accepts a legacy 3-digit supersedes target', () => { + // Pre-0.2.0 content roots wrote 3-digit sequences. A correction must + // be able to point at one, or the oldest records become uncorrectable + // (principle 04 — cold readers of old YAML keep working). + const r = Request.create({ + id: RequestId.generate(d, 5), + from: 'alice', + action: 'a', + reason: 'r', + supersedes: '2026-01-02-007', + }); + assert.equal(r.supersedes, '2026-01-02-007'); +}); + +test('Request.restore preserves supersedes on round-trip', () => { + const r = Request.restore({ + id: RequestId.generate(d, 6), + from: MemberName.of('alice'), + action: 'custom title (no id mention)', + reason: 'custom reason (no id mention)', + state: 'pending', + createdAt: '2026-04-14T00:00:00.000Z', + statusLog: [ + { state: 'pending', by: 'alice', at: '2026-04-14T00:00:00.000Z' }, + ], + reviews: [], + supersedes: '2026-04-14-0009', + }); + assert.equal(r.supersedes, '2026-04-14-0009'); + assert.equal(r.toJSON()['supersedes'], '2026-04-14-0009'); +}); + diff --git a/tests/interface/chain.test.ts b/tests/interface/chain.test.ts index fdea023c..5cc815dd 100644 --- a/tests/interface/chain.test.ts +++ b/tests/interface/chain.test.ts @@ -291,3 +291,27 @@ test('chain: one-way reference shows no ↔ marker', () => { cleanup(); } }); + +// ── supersedes reaches the id-scanner ── +// +// Guard against a specific regression: the correction link used to live +// in `action` prose, where chain saw it for free. Once it became a +// structured field, omitting it here would make `gate chain ` +// silently stop finding corrections — more precise storage, less +// reachable record. Red is reachable: drop the `parts.push` in +// gatherRequestText and this test fails. +test('gatherRequestText: includes the structured supersedes link', () => { + const text = gatherRequestText({ + action: 'a correction with no id in its prose', + reason: 'the earlier figure was wrong', + supersedes: '2026-04-15-0007', + }); + assert.ok(text.includes('2026-04-15-0007')); + const refs = extractReferences(text); + assert.ok(refs.requestIds.includes('2026-04-15-0007')); +}); + +test('gatherRequestText: omits supersedes when absent', () => { + const text = gatherRequestText({ action: 'a', reason: 'r' }); + assert.equal(text, 'a\nr'); +});