From 8c09f0315c0eefe0dfff6c2a17178041213f7ef5 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:50:44 +0100 Subject: [PATCH 01/36] feat(capture): count skipped records on the board and name the refusing layer The bare status board excluded a record the reader refused from all three totals and printed a bare error beside it, so a reader could not tell whether the writer or the validator was the side that was wrong, and the header under-reported the ledger. Each SkipRecord now carries the reader layer that refused it (filename, read, frontmatter, schema, invariant), StatusResult carries skipped_count, and the header counts skipped records beside the totals. The list and mentions renders name the layer too. The other halves of the record were already fixed at the base: the slug truncation trims its separator (recordid.Slug) and the --severity, --category and --source help names each vocabulary (enumHelp). Refs: iss-2609120452071388 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 7 +- commands/capture.md | 10 ++ internal/core/capture/capture.go | 46 ++++++++-- internal/core/capture/skiplayer_test.go | 91 +++++++++++++++++++ internal/core/capture/workflow.go | 11 ++- internal/surface/cli/capture_surface_test.go | 36 ++++++++ internal/surface/cli/cli.go | 35 ++++++- 7 files changed, 218 insertions(+), 18 deletions(-) create mode 100644 internal/core/capture/skiplayer_test.go diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index b8054dbf1..9b16747a5 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -40,7 +40,12 @@ binary. wontfix counts, the most recent open issues, and a three-way routing hint that closes on the next move (capture it, shape it as an intent, or, for a big unproven idea, run the optional `abcd ideate` admission gauntlet). It creates, -moves and mutates nothing. +moves and mutates nothing. A file that claims to be a record and that the reader +refuses is counted in none of the three totals, so the board counts it beside +them and names, for each one, the reader layer that refused it: the filename, +the guarded read, the frontmatter parse, the schema or the folder and filename +invariants. The layer is what tells a reader whether the record or the reader is +the side to fix (iss-2609120452071388). **`/abcd:capture ""`** is the fast path: it appends a structured entry with an auto-assigned `iss-N` and writes it to `open/`. Provenance and taxonomy diff --git a/commands/capture.md b/commands/capture.md index 71cb2e2d4..223b05357 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -33,6 +33,16 @@ Summarise the JSON for the user: `open_count` / `resolved_count` / `wontfix_count`, and for each entry in `recent_open` its `id`, `severity`, and `slug`. No `iss-*.md` file is created, moved, or mutated by this invocation. +When `skipped_count` is non-zero, say so: those are files that claim to be +records and that none of the three totals counts, because the reader refused +them. Each entry in `skipped` carries its `path`, the `layer` that refused it +and the `error`. The layer tells the user which side to fix: `filename` (the +name is not a well-formed record name), `read` (the guarded read refused the +file itself, such as a symlink or an oversize body), `frontmatter` (the bytes do +not parse), `schema` (a key or value the issue schema does not accept) or +`invariant` (the record disagrees with its filename or with the folder holding +it). + **Which ledger?** A half-formed observation, question, or nitpick goes to `/abcd:capture "…"`; a user-facing change you want to ship goes to `/abcd:intent "…"`. For a big, unproven idea there is an optional third route: diff --git a/internal/core/capture/capture.go b/internal/core/capture/capture.go index 906bf37a0..9cadb691a 100644 --- a/internal/core/capture/capture.go +++ b/internal/core/capture/capture.go @@ -257,11 +257,38 @@ type ListRequest struct { } // SkipRecord surfaces a corrupt/invalid ledger file without failing the scan. +// +// Layer names WHICH reader stage refused the file (iss-2609120452071388). A +// skip reported as a bare error left the reader unable to tell whether the +// writer or the validator was the side that was wrong: a name the grammar +// refuses, a leaf the guarded read refuses, and a value the schema refuses are +// three different defects with three different remedies. type SkipRecord struct { - Path string `json:"path"` - Error string `json:"error"` + Path string `json:"path"` + Layer SkipLayer `json:"layer"` + Error string `json:"error"` } +// SkipLayer is the reader stage that refused a ledger file, in scan order. +type SkipLayer string + +// The reader's stages, in the order a file meets them. +const ( + // SkipLayerName: the filename claims a record and is not a well-formed one. + SkipLayerName SkipLayer = "filename" + // SkipLayerRead: the guarded read refused the leaf (a FIFO, a symlink, an + // oversize body, an I/O error) — nothing about the record's content. + SkipLayerRead SkipLayer = "read" + // SkipLayerFrontmatter: the bytes do not parse as a frontmatter block. + SkipLayerFrontmatter SkipLayer = "frontmatter" + // SkipLayerSchema: the frontmatter parses and the issue schema refuses a + // key or value in it. + SkipLayerSchema SkipLayer = "schema" + // SkipLayerInvariant: schema-clean, and the record disagrees with where it + // sits — its filename, or the status folder holding it. + SkipLayerInvariant SkipLayer = "invariant" +) + // ListResult is Issues sorted ascending by numeric N plus a corrupt roster. type ListResult struct { Issues []Issue `json:"issues"` @@ -276,11 +303,16 @@ type StatusRequest struct { // StatusResult is the bare-invocation status snapshot (guaranteed no mutation). type StatusResult struct { - OpenCount int `json:"open_count"` - ResolvedCount int `json:"resolved_count"` - WontfixCount int `json:"wontfix_count"` - RecentOpen []Issue `json:"recent_open"` // up to 10, newest first - Skipped []SkipRecord `json:"skipped"` + OpenCount int `json:"open_count"` + ResolvedCount int `json:"resolved_count"` + WontfixCount int `json:"wontfix_count"` + // SkippedCount is the number of files that claim to be records and that + // none of the three totals counts, because the reader refused them. It is + // len(Skipped), carried as a count beside the others so the board states + // what it excluded next to what it counted (iss-2609120452071388). + SkippedCount int `json:"skipped_count"` + RecentOpen []Issue `json:"recent_open"` // up to 10, newest first + Skipped []SkipRecord `json:"skipped"` } // Sentinel errors the surface maps to exit codes and messages. Core never diff --git a/internal/core/capture/skiplayer_test.go b/internal/core/capture/skiplayer_test.go new file mode 100644 index 000000000..ae7c305b3 --- /dev/null +++ b/internal/core/capture/skiplayer_test.go @@ -0,0 +1,91 @@ +package capture + +import ( + "os" + "path/filepath" + "testing" +) + +// TestStatusSkipNamesTheRefusingLayerAndIsCounted is the remaining half of +// iss-2609120452071388: a record the reader refuses is excluded from every +// total, so the board must COUNT what it excluded and SAY which layer refused +// each one. A reader told only "skipped … malformed frontmatter" cannot tell +// whether the writer or the validator is the side that is wrong, and a count +// that silently omits the record under-reports the ledger. +// +// One planted record per layer, each wrong in exactly one way, so the layer +// reported can be attributed to nothing else. +func TestStatusSkipNamesTheRefusingLayerAndIsCounted(t *testing.T) { + repo, ir := ledger(t) + if _, err := Capture(CaptureRequest{ + RepoRoot: repo, IssuesRoot: ir, Text: "a well formed finding", Severity: SeverityMinor, + Category: "bug", Source: "manual-test", Slug: "fine", FoundDuring: "t", + }); err != nil { + t.Fatal(err) + } + valid := func(id, slug string) string { + return "---\nschema_version: 1\nid: \"" + id + "\"\nslug: \"" + slug + "\"\n" + + "severity: \"minor\"\ncategory: \"bug\"\nsource: \"manual-test\"\nfound_during: \"t\"\n" + } + open := filepath.Join(ir, "open") + plant := map[string]string{ + // name: the filename claims a record and is not a well-formed one. + "iss-11-bad_name.md": valid("iss-11", "bad-name") + "---\n\nbody\n", + // frontmatter: no closing fence, so nothing parses. + "iss-12-unclosed.md": valid("iss-12", "unclosed") + "\nbody with no closer\n", + // schema: parses, and carries a source outside the closed vocabulary. + "iss-13-bad-source.md": "---\nschema_version: 1\nid: \"iss-13\"\nslug: \"bad-source\"\n" + + "severity: \"minor\"\ncategory: \"bug\"\nsource: \"autonomous-hunt\"\nfound_during: \"t\"\n---\n\nbody\n", + // invariant: schema-clean, but the filename slug disagrees with the field. + "iss-14-other-slug.md": valid("iss-14", "the-real-slug") + "---\n\nbody\n", + } + for name, content := range plant { + if err := os.WriteFile(filepath.Join(open, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + st, err := Status(StatusRequest{RepoRoot: repo, IssuesRoot: ir}) + if err != nil { + t.Fatal(err) + } + if st.OpenCount != 1 { + t.Fatalf("open count = %d, want the 1 readable record", st.OpenCount) + } + if st.SkippedCount != len(plant) || len(st.Skipped) != st.SkippedCount { + t.Fatalf("skipped_count = %d with %d roster entries; want both %d, so the count and the roster agree", + st.SkippedCount, len(st.Skipped), len(plant)) + } + want := map[string]SkipLayer{ + "iss-11-bad_name.md": SkipLayerName, + "iss-12-unclosed.md": SkipLayerFrontmatter, + "iss-13-bad-source.md": SkipLayerSchema, + "iss-14-other-slug.md": SkipLayerInvariant, + } + for _, sk := range st.Skipped { + base := filepath.Base(sk.Path) + if sk.Layer != want[base] { + t.Errorf("%s skipped by layer %q, want %q (error: %s)", base, sk.Layer, want[base], sk.Error) + } + } +} + +// TestListSkipOfAnUnreadableLeafNamesTheReadLayer pins the fifth layer: a leaf +// the guarded read refuses (here a dangling symlink) is a read refusal, not a +// statement about the record's content. +func TestListSkipOfAnUnreadableLeafNamesTheReadLayer(t *testing.T) { + repo, ir := ledger(t) + if err := ensureLedgerDirs(repo, ir); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(ir, "open", "nowhere.md"), filepath.Join(ir, "open", "iss-1-broken.md")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + res, err := List(ListRequest{RepoRoot: repo, IssuesRoot: ir, State: StateOpen}) + if err != nil { + t.Fatal(err) + } + if len(res.Skipped) != 1 || res.Skipped[0].Layer != SkipLayerRead { + t.Fatalf("want one skip by the read layer, got %+v", res.Skipped) + } +} diff --git a/internal/core/capture/workflow.go b/internal/core/capture/workflow.go index d7337dc41..3da515440 100644 --- a/internal/core/capture/workflow.go +++ b/internal/core/capture/workflow.go @@ -646,6 +646,7 @@ func Status(req StatusRequest) (StatusResult, error) { res.ResolvedCount = len(resolved) res.WontfixCount = len(wontfix) res.Skipped = append(append(append([]SkipRecord{}, skOpen...), skRes...), skWf...) + res.SkippedCount = len(res.Skipped) // The same predicate List uses, over the scan already in hand: skOpen carries // the records open/ holds and the reader refused, and they block too. @@ -783,7 +784,7 @@ func scanLedger(issuesRoot string, state State) ([]Issue, []SkipRecord) { // frontmatter agreement, below in validateInvariants — but that is a // judgement on a record, not the question of whether one exists. if filepath.Ext(name) == ".md" && reIssNameClaim.MatchString(name) { - skipped = append(skipped, SkipRecord{Path: path, Error: fmt.Errorf( + skipped = append(skipped, SkipRecord{Path: path, Layer: SkipLayerName, Error: fmt.Errorf( "%w: filename %q is not a well-formed record name (iss-N[-slug].md)", ErrInvariantViolation, name).Error()}) } @@ -799,20 +800,20 @@ func scanLedger(issuesRoot string, state State) ([]Issue, []SkipRecord) { // surfaces already render, never a hang and never serialized. content, err := readRecordGuarded(path) if err != nil { - skipped = append(skipped, SkipRecord{Path: path, Error: err.Error()}) + skipped = append(skipped, SkipRecord{Path: path, Layer: SkipLayerRead, Error: err.Error()}) continue } fm, body, err := parseFrontmatterAndBody(content) if err != nil { - skipped = append(skipped, SkipRecord{Path: path, Error: err.Error()}) + skipped = append(skipped, SkipRecord{Path: path, Layer: SkipLayerFrontmatter, Error: err.Error()}) continue } if err := validateStrict(fm); err != nil { - skipped = append(skipped, SkipRecord{Path: path, Error: err.Error()}) + skipped = append(skipped, SkipRecord{Path: path, Layer: SkipLayerSchema, Error: err.Error()}) continue } if err := validateInvariants(fm, sub, path); err != nil { - skipped = append(skipped, SkipRecord{Path: path, Error: err.Error()}) + skipped = append(skipped, SkipRecord{Path: path, Layer: SkipLayerInvariant, Error: err.Error()}) continue } issues = append(issues, issueFromFrontmatter(fm, sub, path, body)) diff --git a/internal/surface/cli/capture_surface_test.go b/internal/surface/cli/capture_surface_test.go index fc4896e6e..88e869497 100644 --- a/internal/surface/cli/capture_surface_test.go +++ b/internal/surface/cli/capture_surface_test.go @@ -477,6 +477,42 @@ func TestCaptureStatusBoardRendersSkipped(t *testing.T) { } } +// TestCaptureStatusBoardCountsWhatItSkippedAndNamesTheLayer is the surface half +// of iss-2609120452071388: the board's totals exclude a refused record, so the +// header must count what it excluded beside what it counted, and each skipped +// line must name the reader layer that refused it — the difference between a +// record abcd wrote wrongly and a record the schema has outgrown. +func TestCaptureStatusBoardCountsWhatItSkippedAndNamesTheLayer(t *testing.T) { + repo := captureLedgerRepo(t) + runCLI(t, "capture", "a well formed observation", "--slug", "fine", "--json") + bad := filepath.Join(repo, ".abcd", "work", "issues", "open", "iss-901-hunted.md") + if err := os.WriteFile(bad, []byte( + "---\nschema_version: 1\nid: iss-901\nslug: hunted\nseverity: minor\ncategory: bug\nsource: autonomous-hunt\nfound_during: t\n---\n\nan issue\n"), 0o644); err != nil { + t.Fatal(err) + } + + board := string(runCLI(t, "capture")) + if !strings.Contains(board, "open 1 · resolved 0 · wontfix 0 · skipped 1") { + t.Fatalf("the board header must count the skipped record beside the totals:\n%s", board) + } + if !strings.Contains(board, "skipped .abcd/work/issues/open/iss-901-hunted.md (refused by the schema layer)") { + t.Fatalf("the skipped line must name the layer that refused the record:\n%s", board) + } + + var env struct { + SkippedCount int `json:"skipped_count"` + Skipped []struct { + Layer string `json:"layer"` + } `json:"skipped"` + } + if err := json.Unmarshal(runCLI(t, "capture", "--json"), &env); err != nil { + t.Fatal(err) + } + if env.SkippedCount != 1 || len(env.Skipped) != 1 || env.Skipped[0].Layer != "schema" { + t.Fatalf("--json must carry skipped_count and each skip's layer, got %+v", env) + } +} + // TestCaptureLapsedAtWritesTheGivenInstant pins the flag half of spc-60: the // instant handed to --lapsed-at is the instant committed to the record. The // record id is minted from the wall clock, so a surface that dropped, rounded or diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 798b976eb..c6e42559a 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3256,8 +3256,11 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { return err } return render(cmd.OutOrStdout(), *asJSON, board, func(w io.Writer) { - fmt.Fprintf(w, "abcd capture — open %d · resolved %d · wontfix %d\n", - st.OpenCount, st.ResolvedCount, st.WontfixCount) + // A refused record is in none of the three totals, so the header + // counts it beside them (iss-2609120452071388): the reader sees + // what the board excluded in the same line as what it counted. + fmt.Fprintf(w, "abcd capture — open %d · resolved %d · wontfix %d%s\n", + st.OpenCount, st.ResolvedCount, st.WontfixCount, skippedTally(st.SkippedCount)) if len(st.RecentOpen) > 0 { fmt.Fprintf(w, "recent open:\n") for _, iss := range st.RecentOpen { @@ -3271,7 +3274,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { // the records it dropped. Path and Error echo the malformed file's // own name and bytes, so both are sanitised before the terminal. for _, sk := range st.Skipped { - fmt.Fprintf(w, " skipped %s: %s\n", termsafe.Sanitize(sk.Path), termsafe.Sanitize(sk.Error)) + fmt.Fprint(w, skippedLine(sk)) } // Beside the skipped roster, and for the same reason it is // there: a record the board does not name is one nobody is @@ -3449,7 +3452,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { for _, sk := range res.Skipped { // Path and Error echo a malformed issue file's own name and content // (err.Error() carries offending bytes), so sanitise before the terminal. - fmt.Fprintf(w, " skipped %s: %s\n", termsafe.Sanitize(sk.Path), termsafe.Sanitize(sk.Error)) + fmt.Fprint(w, skippedLine(sk)) } }) }, @@ -3493,7 +3496,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { termsafe.Sanitize(top.Subject), moreEvidenceNote(len(row.Evidence))) } for _, sk := range res.Skipped { - fmt.Fprintf(w, " skipped %s: %s\n", termsafe.Sanitize(sk.Path), termsafe.Sanitize(sk.Error)) + fmt.Fprint(w, skippedLine(sk)) } if len(res.Rows) > 0 { fmt.Fprintf(w, "\nA mention is not a fix. Read the commit, then resolve what it fixed:\n"+ @@ -4164,6 +4167,28 @@ func parseRecurs(raw string) ([]string, error) { return ids, nil } +// skippedTally is the board header's count of records the reader refused, or +// "" when it refused none, so an untroubled ledger's header is unchanged. +func skippedTally(n int) string { + if n == 0 { + return "" + } + return fmt.Sprintf(" · skipped %d (refused by the reader, in none of the totals)", n) +} + +// skippedLine renders one refused ledger file: its path, the reader layer that +// refused it, and the refusal (iss-2609120452071388). The layer is what tells a +// reader whether the file or the reader is the side to fix. Path and Error echo +// the file's own name and bytes, so both are sanitised before the terminal. +func skippedLine(sk capture.SkipRecord) string { + layer := "the reader" + if sk.Layer != "" { + layer = "the " + string(sk.Layer) + " layer" + } + return fmt.Sprintf(" skipped %s (refused by %s): %s\n", + termsafe.Sanitize(sk.Path), layer, termsafe.Sanitize(sk.Error)) +} + // blockedNote renders the derived-priority annotation for a row: when the issue // has blocked_by targets still open, " [blocked-by iss-1,iss-2]"; otherwise "". func blockedNote(iss capture.Issue) string { From 98d8d706ac1ac282218a7d3b852269c0f77ec343 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:51:22 +0100 Subject: [PATCH 02/36] test(capture): pin that a derived slug always satisfies its own validator The property half of iss-2609120452071388's acceptance: 20000 seeded adversarial inputs (separator runs, punctuation, non-ASCII, lengths either side of the 60-character budget) each derive a slug the reader's SlugRe accepts and that normalisation leaves unchanged. The trim that makes it hold already stands in recordid.Slug; the property had no test. Refs: iss-2609120452071388 Assisted-by: Claude:claude-opus-5-5 --- internal/core/capture/slugproperty_test.go | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 internal/core/capture/slugproperty_test.go diff --git a/internal/core/capture/slugproperty_test.go b/internal/core/capture/slugproperty_test.go new file mode 100644 index 000000000..db13c919e --- /dev/null +++ b/internal/core/capture/slugproperty_test.go @@ -0,0 +1,50 @@ +package capture + +import ( + "math/rand" + "strings" + "testing" +) + +// TestDerivedSlugAlwaysSatisfiesItsOwnValidator is the property half of +// iss-2609120452071388's acceptance: whatever text a caller captures, the slug +// derived and truncated from it satisfies the validator the reader applies — +// no trailing, leading or doubled separator. The writer and the reader must +// never disagree about what a legal slug is, because a record abcd writes and +// then refuses to read drops silently out of every count. +// +// The inputs are adversarial by construction and seeded, so a failure +// reproduces: separator runs, punctuation, non-ASCII, and lengths either side +// of the 60-character budget, which is where the reported trailing hyphen was +// cut. +func TestDerivedSlugAlwaysSatisfiesItsOwnValidator(t *testing.T) { + rng := rand.New(rand.NewSource(20260912)) + pieces := []string{ + "a", "bc", "def", "ghij", "klmnopqrstu", "0", "42", "-", "--", " ", " ", "_", ".", "/", + ":", "!", "é", "ß", "日本", "​", "\t", "ab-", "-cd", "x_y", "A", "Z9", + } + for i := 0; i < 20000; i++ { + var b strings.Builder + for n := rng.Intn(40); n >= 0; n-- { + b.WriteString(pieces[rng.Intn(len(pieces))]) + } + text := b.String() + derived := deriveSlug(text) + if derived == "" { + continue // an input with no slug-able rune is refused upstream as empty + } + got, err := normaliseSlug(derived) + if err != nil { + t.Fatalf("normaliseSlug(deriveSlug(%q)) refused %q: %v", text, derived, err) + } + if got != derived { + t.Fatalf("deriveSlug(%q) = %q, which normalises to a different %q", text, derived, got) + } + if !reSlug.MatchString(got) { + t.Fatalf("deriveSlug(%q) = %q, which the reader's slug validator refuses", text, got) + } + if len(got) > 60 { + t.Fatalf("deriveSlug(%q) = %q exceeds the 60-character budget", text, got) + } + } +} From 6073ca1cbda7828858b22818476788574be3988e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:51:38 +0100 Subject: [PATCH 03/36] =?UTF-8?q?chore:=20resolve=20iss-2609120452071388?= =?UTF-8?q?=20=E2=80=94=20skipped=20records=20counted=20and=20layer-named?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slug and help halves were fixed before this lane; the skip count and layer diagnostic land in 8c09f031. The autonomous-hunt vocabulary question is captured separately as iss-2609251151293535 for a product ruling. Resolves: iss-2609120452071388 Refs: iss-2609251151293535 Assisted-by: Claude:claude-opus-5-5 --- ...ce-vocabulary-has-no-member-for-a-finding-an.md | 14 ++++++++++++++ ...or-can-emit-a-trailing-hyphen-that-its-own-s.md | 8 ++++++++ 2 files changed, 22 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609251151293535-the-source-vocabulary-has-no-member-for-a-finding-an.md rename .abcd/work/issues/{open => resolved}/iss-2609120452071388-the-slug-generator-can-emit-a-trailing-hyphen-that-its-own-s.md (88%) diff --git a/.abcd/work/issues/open/iss-2609251151293535-the-source-vocabulary-has-no-member-for-a-finding-an.md b/.abcd/work/issues/open/iss-2609251151293535-the-source-vocabulary-has-no-member-for-a-finding-an.md new file mode 100644 index 000000000..06b1e4f9c --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251151293535-the-source-vocabulary-has-no-member-for-a-finding-an.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251151293535" +slug: "the-source-vocabulary-has-no-member-for-a-finding-an" +severity: "minor" +category: "process" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/issueschema/issueschema.go" +--- + +The source vocabulary has no member for a finding an autonomous bug-hunt loop files, and a downstream loop wrote source autonomous-hunt, which the reader refuses and skips. Decomposed out of iss-2609120452071388 when its code halves were fixed, because this half is a closed-vocabulary ruling for the product thinker, not an implementer's fix: either such a loop uses an existing member (agent-finding is the nearest) or autonomous-hunt joins issueschema.Sources, with record-lint and the help text following from the one list. Until it is ruled, a record carrying it is skipped with the schema layer named on the board. diff --git a/.abcd/work/issues/open/iss-2609120452071388-the-slug-generator-can-emit-a-trailing-hyphen-that-its-own-s.md b/.abcd/work/issues/resolved/iss-2609120452071388-the-slug-generator-can-emit-a-trailing-hyphen-that-its-own-s.md similarity index 88% rename from .abcd/work/issues/open/iss-2609120452071388-the-slug-generator-can-emit-a-trailing-hyphen-that-its-own-s.md rename to .abcd/work/issues/resolved/iss-2609120452071388-the-slug-generator-can-emit-a-trailing-hyphen-that-its-own-s.md index 6f75da62f..868d8efa4 100644 --- a/.abcd/work/issues/open/iss-2609120452071388-the-slug-generator-can-emit-a-trailing-hyphen-that-its-own-s.md +++ b/.abcd/work/issues/resolved/iss-2609120452071388-the-slug-generator-can-emit-a-trailing-hyphen-that-its-own-s.md @@ -11,6 +11,10 @@ production_mode: hand-written found_at: "internal/core/issueschema/issueschema.go" deferred_after: "v0.8.0" deferral_reason: "The headline defect does not reproduce on this tree, and what remains of the record is not a bug fix. Every site that truncates a slug into a filename already trims the separator it cut against: capture roots.go, intent create.go and decide decide.go each wrap the truncation in a trim, and a sweep finds no fourth truncation site in the tree. Each deriver was run over 200000 adversarial inputs on a scratch copy, mixing separator runs, punctuation, non-ASCII and lengths either side of the 60-character budget, and none emitted a slug its own validator refuses. The three trims have stood since those functions were written, so the downstream record that prompted this was produced by something other than the current generator, which is consistent with its siblings carrying the source value autonomous-hunt that abcd capture would itself have refused. The three items that remain are real and none is contained: naming the members of the source and category flags in the help text, and making the status board say which layer refused a record it skipped, are user-facing surface changes that must not ship without a record, and deciding autonomous-hunt is a closed-vocabulary question this record routes to the product thinker rather than to an implementer. Waits on that vocabulary ruling, which is what the other two should land beside." +resolution: "Slug truncation and the --help enums were already fixed at the base: recordid.Slug trims the separator it cuts against and enumHelp names the severity, category and source vocabularies in capture --help. This change built the remaining half: every skipped ledger file carries the reader layer that refused it, the status board counts skipped records beside its totals (skipped_count in --json), and a seeded property test pins that a derived slug always satisfies SlugRe. The autonomous-hunt vocabulary question is decomposed to iss-2609251151293535 for the product thinker." +impact: additive +resolved_by: + commit: "8c09f031" --- Reported from a downstream repository using abcd, where `abcd capture` (bare, @@ -106,3 +110,7 @@ wrong guesses independently of it. - **Given** `capture --help`, **when** a caller reads it, **then** the legal values for `--source` and `--category` are named inline, as `--severity` already names its four. + +## Grounds + +- pursued: a reader shown a skipped record can now tell whether the writer or the schema is wrong; a skipped record whose layer is empty, or a board whose header omits a skip it lists, would show it wrong From 166d35ac2f4af4a083875d2a8fb371e451c5fa3e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:58:22 +0100 Subject: [PATCH 04/36] fix(capture,intent): encode hidden runes at the record-write boundary A bidi override, zero-width rune, C1 control or DEL typed into a capture body, a resolution or wontfix note, a grounds entry or an intent headline reached the committed record verbatim. termsafe gains EncodeHiddenRunesBlock, the multi-line form of EncodeHiddenRunes that keeps the line feed, the tab and a CRLF pair, and every ledger and intent-draft free-text write now passes through one of the two after redaction. grounds.New encodes the text it validates, so both grounds writers are covered at one site. The grounds a wontfix derives from its reason now go through grounds.NewDerived, which applies ValidateText's control-character check (held once as validateControl) and the encoding, leaving only the substance floor off. A control character in a wontfix reason is refused at the grounds boundary before any write, not by the serialiser under the ledger lock. Refs: iss-2608301206073609, iss-2608301244450106 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 8 ++ internal/core/capture/grounds.go | 19 ++- internal/core/capture/hiddenrunes_test.go | 110 ++++++++++++++++++ internal/core/capture/workflow.go | 14 +++ internal/core/grounds/grounds.go | 52 +++++++-- internal/core/intent/create.go | 11 +- internal/core/intent/hiddenrunes_test.go | 50 ++++++++ internal/termsafe/encode_hidden_runes.go | 26 +++++ .../encode_hidden_runes_block_test.go | 27 +++++ 9 files changed, 301 insertions(+), 16 deletions(-) create mode 100644 internal/core/capture/hiddenrunes_test.go create mode 100644 internal/core/intent/hiddenrunes_test.go create mode 100644 internal/termsafe/encode_hidden_runes_block_test.go diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index 9b16747a5..502a56f01 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -254,6 +254,14 @@ above is correct under both. Where a merge produces two reachable candidates, prefer the commit that carries the change over the merge commit, whose diff is the whole pull request rather than the fix. +Free text is written losslessly but never invisibly. A bidi override, a +zero-width rune, a C1 control, DEL or any other character a terminal would hide +is percent-encoded as its UTF-8 bytes wherever a verb writes caller text into a +record: the capture body and its location and context fields, a resolution or +wontfix note, and every grounds entry, on this surface and in the intent drafts +promotion and `abcd intent` mint. A line break and a tab in the body are left as +they are, because they are its structure (iss-2608301206073609). + The record body is free-form. One part of it is not, and it is where grounds land. diff --git a/internal/core/capture/grounds.go b/internal/core/capture/grounds.go index 84be9d671..6ed0480e8 100644 --- a/internal/core/capture/grounds.go +++ b/internal/core/capture/grounds.go @@ -67,7 +67,9 @@ func optionalGrounds(repoRoot, verb, raw string) (g *grounds.Grounds, redacted i // from the reason it already takes, or the caller's own text when the conjecture // is worth stating separately from the user-facing reason. // -// The reason-derived form deliberately SKIPS the substance floor. A wontfix +// The reason-derived form deliberately SKIPS the substance floor, and only the +// floor: it passes the same control-character check and hidden-rune encoding a +// supplied value does (grounds.NewDerived). A wontfix // reason is already a required, non-empty value with its own contract, and // putting a new length rule on it here would refuse records the ledger has // always accepted — a refusal this change was never asked for. The floor governs @@ -87,15 +89,24 @@ func wontfixGrounds(repoRoot, raw, reason string) (g grounds.Grounds, redacted i "wontfix: %w: wontfix_reason must be a non-empty string; nothing written", ErrGroundsRefused) } redText, _, deg := redactLedgerText(repoRoot, reason) - folded := grounds.Fold(redText) - if folded == "" { + if grounds.Fold(redText) == "" { return grounds.Grounds{}, 0, "", fmt.Errorf( "wontfix: %w: the reason is empty after redaction; nothing written", ErrGroundsRefused) } + // Through core/grounds's derived-value constructor, not a struct literal: + // the control-character refusal a supplied value meets is met here too, + // at the grounds boundary and before any write, rather than by the + // frontmatter serialiser under the ledger lock (iss-2608301244450106); + // and hidden runes are encoded as every written grounds text is. Only + // the substance floor is left off, for the reason given above. + g, err := grounds.NewDerived(grounds.Declined, redText) + if err != nil { + return grounds.Grounds{}, 0, "", fmt.Errorf("wontfix: %w: %v; nothing written", ErrGroundsRefused, err) + } // The count is deliberately dropped, not added: the derived grounds ARE the // reason, and transition redacts and counts that same operand on its way to // the note field. Returning it here made one redactable span report as two. - return grounds.Grounds{Token: grounds.Declined, Text: folded}, 0, deg, nil + return g, 0, deg, nil } g, redacted, degraded, err = requireGrounds(repoRoot, "wontfix", raw) if err != nil { diff --git a/internal/core/capture/hiddenrunes_test.go b/internal/core/capture/hiddenrunes_test.go new file mode 100644 index 000000000..f03387832 --- /dev/null +++ b/internal/core/capture/hiddenrunes_test.go @@ -0,0 +1,110 @@ +package capture + +import ( + "errors" + "strings" + "testing" +) + +// hiddenRunes is one of each class the record-write boundary must encode: a +// bidi override (Trojan-Source reordering), a zero-width space, a C1 control +// and DEL. Each reached a committed record verbatim (iss-2608301206073609). +var hiddenRunes = []string{"‮", "​", "\u0085", "\x7f"} + +// assertNoHiddenRune fails when any hidden rune survives into the record, and +// when its percent-encoded form is absent — encoded, not dropped, so the record +// stays lossless. +func assertNoHiddenRune(t *testing.T, what, raw string) { + t.Helper() + for _, r := range hiddenRunes { + if strings.Contains(raw, r) { + t.Errorf("%s: the hidden rune %q reached the committed record verbatim:\n%q", what, r, raw) + } + } + for _, enc := range []string{"%E2%80%AE", "%E2%80%8B", "%C2%85", "%7F"} { + if !strings.Contains(raw, enc) { + t.Errorf("%s: the encoded form %s is missing, so a rune was dropped rather than encoded:\n%q", what, enc, raw) + } + } +} + +func hiddenText(prefix string) string { + return prefix + " one‮two three​four five\u0085six seven\x7feight" +} + +func TestCaptureBodyEncodesHiddenRunes(t *testing.T) { + repo, ir := ledger(t) + res, err := Capture(CaptureRequest{ + RepoRoot: repo, IssuesRoot: ir, Text: hiddenText("a body") + "\nsecond line\n\tindented", + Severity: SeverityMinor, Category: "bug", Source: "manual-test", Slug: "hidden", FoundDuring: "t", + }) + if err != nil { + t.Fatal(err) + } + raw := readRaw(t, ir, res.ID) + assertNoHiddenRune(t, "capture body", raw) + if !strings.Contains(raw, "\nsecond line\n\tindented") { + t.Errorf("the body's own line structure was altered:\n%q", raw) + } +} + +func TestWontfixReasonAndDerivedGroundsEncodeHiddenRunes(t *testing.T) { + repo, ir := ledger(t) + res, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "a finding", + Severity: SeverityMinor, Category: "bug", Source: "manual-test", Slug: "w", FoundDuring: "t"}) + if err != nil { + t.Fatal(err) + } + if _, err := Wontfix(WontfixRequest{RepoRoot: repo, IssuesRoot: ir, ID: res.ID, Reason: hiddenText("the reason")}); err != nil { + t.Fatal(err) + } + raw := readRaw(t, ir, res.ID) + fm, body, _ := strings.Cut(raw[4:], "\n---\n") + assertNoHiddenRune(t, "wontfix_reason", fm) + assertNoHiddenRune(t, "derived grounds", body) +} + +func TestResolveNoteAndGroundsEncodeHiddenRunes(t *testing.T) { + repo, ir := ledger(t) + res, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "a finding", + Severity: SeverityMinor, Category: "bug", Source: "manual-test", Slug: "r", FoundDuring: "t"}) + if err != nil { + t.Fatal(err) + } + if _, err := Resolve(ResolveRequest{RepoRoot: repo, IssuesRoot: ir, ID: res.ID, Impact: "fix", + Resolution: hiddenText("the note"), + Grounds: "pursued: " + hiddenText("the conjecture being acted on")}); err != nil { + t.Fatal(err) + } + raw := readRaw(t, ir, res.ID) + fm, body, _ := strings.Cut(raw[4:], "\n---\n") + assertNoHiddenRune(t, "resolution", fm) + assertNoHiddenRune(t, "resolve grounds", body) +} + +// TestWontfixDerivedGroundsRefuseAControlCharacter is iss-2608301244450106: the +// grounds a wontfix derives from its reason go through the SAME control- +// character check supplied grounds do (grounds.ValidateText's), so the refusal +// arrives at the grounds boundary as a grounds refusal — not later, at the +// frontmatter serialiser, under the ledger lock. The substance floor stays off +// for a derived value: a terse reason is still a legal wontfix. +func TestWontfixDerivedGroundsRefuseAControlCharacter(t *testing.T) { + repo, ir := ledger(t) + res, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "a finding", + Severity: SeverityMinor, Category: "bug", Source: "manual-test", Slug: "c", FoundDuring: "t"}) + if err != nil { + t.Fatal(err) + } + before := readRaw(t, ir, res.ID) + _, err = Wontfix(WontfixRequest{RepoRoot: repo, IssuesRoot: ir, ID: res.ID, Reason: "dup\x0bof another"}) + if !errors.Is(err, ErrGroundsRefused) || !strings.Contains(err.Error(), "U+000B") { + t.Fatalf("want a grounds refusal naming U+000B, got %v", err) + } + if after := readRaw(t, ir, res.ID); after != before { + t.Fatal("a refused wontfix changed the record") + } + // The floor is not applied to a derived value: a one-word reason stands. + if _, err := Wontfix(WontfixRequest{RepoRoot: repo, IssuesRoot: ir, ID: res.ID, Reason: "duplicate"}); err != nil { + t.Fatalf("a terse wontfix reason must still be accepted: %v", err) + } +} diff --git a/internal/core/capture/workflow.go b/internal/core/capture/workflow.go index 3da515440..9874ce703 100644 --- a/internal/core/capture/workflow.go +++ b/internal/core/capture/workflow.go @@ -13,6 +13,7 @@ import ( "github.com/intentdriven/abcd/internal/core/grounds" "github.com/intentdriven/abcd/internal/core/provenance" "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/termsafe" ) // mutationPreamble runs the idempotent pre-mutation steps: sweep orphan @@ -92,6 +93,16 @@ func Capture(req CaptureRequest) (CaptureResult, error) { if err != nil { return CaptureResult{}, err } + // Hidden runes — a bidi override, a zero-width rune, a C1 control, DEL — are + // percent-encoded in the free text the record commits (iss-2608301206073609), + // with termsafe's one encoder for that boundary. It runs AFTER the slug is + // derived, because the slug derivation already drops them as separators and + // an encoded form would put its hex digits into the filename. The line feed + // and tab stay as they are: the body's structure is not a hidden rune, and a + // scalar carrying one is still refused by the serialiser as before. + req.Text = termsafe.EncodeHiddenRunesBlock(req.Text) + req.FoundAt = termsafe.EncodeHiddenRunesBlock(req.FoundAt) + req.FoundDuring = termsafe.EncodeHiddenRunesBlock(req.FoundDuring) // The mint is timestamp-numeric (adr-45; mechanics per spc-33): it consults // no maximum, so the refs-union scan the max+1 allocator needed (iss-115, @@ -481,6 +492,9 @@ func transition(repoRoot, issuesRoot, issID, verb, field, note string, extra []k // redacted — before it is written into the record, never after, so no // rewritten span can reach a field the validator has already passed. redNote, redacted, degraded := redactLedgerText(rr, note) + // Hidden runes are encoded at the record boundary, as the capture body's + // are (iss-2608301206073609). + redNote = termsafe.EncodeHiddenRunesBlock(redNote) newContent, err := setScalarField(content, field, redNote) if err != nil { return err diff --git a/internal/core/grounds/grounds.go b/internal/core/grounds/grounds.go index c6aca9b78..41e9c5fa9 100644 --- a/internal/core/grounds/grounds.go +++ b/internal/core/grounds/grounds.go @@ -12,8 +12,9 @@ // no reading of the grammar and imports nothing from here. It imports core/mdrecord — the record-body machinery the // `## Grounds` section is read and written through — and core/frontmatter, for // the one rule about where a record's frontmatter stops and its body begins; -// otherwise only the standard library: no filesystem, no transport, no record -// store. +// and internal/termsafe, whose hidden-rune encoder a written text passes +// through; otherwise only the standard library: no filesystem, no transport, no +// record store. // // The grounds name the CONJECTURE being acted on, not the route taken. "Planned // it because it is next" restates the decision; "planned it because we expect a @@ -30,6 +31,8 @@ import ( "regexp" "strings" "unicode" + + "github.com/intentdriven/abcd/internal/termsafe" ) // Token is one of the three recorded dispositions a ground may carry. The set is @@ -247,7 +250,29 @@ func New(tok Token, text string) (Grounds, error) { if err := ValidateText(folded); err != nil { return Grounds{}, err } - return Grounds{Token: t, Text: folded}, nil + return Grounds{Token: t, Text: termsafe.EncodeHiddenRunes(folded)}, nil +} + +// NewDerived builds a Grounds whose text is DERIVED from another required value +// rather than supplied to the argument — a wontfix's `declined:` entry stamped +// from its reason. It takes the same path New does with the substance floor left +// off: the text is folded, refused for the control characters ValidateText +// refuses, and hidden runes are encoded. The floor stays with what a caller +// supplies, because the value it is derived from has its own contract and a +// terse reason is a legal one (iss-2608301244450106). +func NewDerived(tok Token, text string) (Grounds, error) { + t, err := ParseToken(string(tok)) + if err != nil { + return Grounds{}, err + } + folded := Fold(text) + if folded == "" { + return Grounds{}, fmt.Errorf("grounds text is empty; name the conjecture being acted on, not the route taken") + } + if err := validateControl(folded); err != nil { + return Grounds{}, err + } + return Grounds{Token: t, Text: termsafe.EncodeHiddenRunes(folded)}, nil } // Fold collapses every run of whitespace to a single space and trims the ends — @@ -293,12 +318,8 @@ func ValidateText(text string) error { // minted and an orphan left behind. Refusing what yamlScalar refuses, before // anything is written, closes the class for all three writers // (iss-2608301206032013). - for _, r := range text { - if r < 0x20 { - return fmt.Errorf( - "grounds text carries the control character U+%04X, which the frontmatter "+ - "serialiser refuses; remove it and restate the conjecture being acted on", r) - } + if err := validateControl(text); err != nil { + return err } units := textUnits(text) // The floor is measured in lexical units, not in runes: a rune count is @@ -356,6 +377,19 @@ func ValidateText(text string) error { return nil } +// validateControl is ValidateText's control-character half, held once so a +// derived text (NewDerived) is refused by exactly the check a supplied one is. +func validateControl(text string) error { + for _, r := range text { + if r < 0x20 { + return fmt.Errorf( + "grounds text carries the control character U+%04X, which the frontmatter "+ + "serialiser refuses; remove it and restate the conjecture being acted on", r) + } + } + return nil +} + // textUnits splits a text into the units MinTextWords counts: one unit per // maximal run of letters in a script that separates words, and one unit per // LETTER in a script that does not. Anything that is not a letter separates diff --git a/internal/core/intent/create.go b/internal/core/intent/create.go index f7fe700b2..e36cd14e7 100644 --- a/internal/core/intent/create.go +++ b/internal/core/intent/create.go @@ -12,6 +12,7 @@ import ( "github.com/intentdriven/abcd/internal/core/changelog" "github.com/intentdriven/abcd/internal/core/provenance" "github.com/intentdriven/abcd/internal/core/recordid" + "github.com/intentdriven/abcd/internal/termsafe" ) // mintLockTimeout bounds how long CreateFromText waits for the intent-store mint @@ -266,9 +267,13 @@ func CreateDraft(repoRoot string, opts DraftOptions) (Intent, error) { if err != nil { return Intent{}, err } - opts.Title = rTitle - opts.PressRelease = rPress - opts.SeedBody = rBody + // Hidden runes — a bidi override, a zero-width rune, a C1 control, DEL — are + // percent-encoded at the same boundary, after redaction, with termsafe's one + // encoder for committed records (iss-2608301206073609). The title is one + // line; the press release and body keep their line structure. + opts.Title = termsafe.EncodeHiddenRunes(rTitle) + opts.PressRelease = termsafe.EncodeHiddenRunesBlock(rPress) + opts.SeedBody = termsafe.EncodeHiddenRunesBlock(rBody) var created Intent err = withIntentMintLock(repoRoot, func() error { diff --git a/internal/core/intent/hiddenrunes_test.go b/internal/core/intent/hiddenrunes_test.go new file mode 100644 index 000000000..7cc668d9d --- /dev/null +++ b/internal/core/intent/hiddenrunes_test.go @@ -0,0 +1,50 @@ +package intent + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/grounds" +) + +// TestCreateFromTextEncodesHiddenRunes: an `abcd intent ""` headline and +// press release reached the committed draft with a bidi override or a +// zero-width rune verbatim (iss-2608301206073609). Both are encoded at the one +// draft-mint primitive, losslessly. +func TestCreateFromTextEncodesHiddenRunes(t *testing.T) { + root := t.TempDir() + it, err := CreateFromText(root, "The card ‮respects​ the reader. More prose here.", TextOptions{}) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(root, it.Path)) + if err != nil { + t.Fatal(err) + } + raw := string(data) + for _, r := range []string{"‮", "​"} { + if strings.Contains(raw, r) { + t.Errorf("hidden rune %q reached the draft verbatim:\n%q", r, raw) + } + } + if !strings.Contains(raw, "# The card %E2%80%AErespects%E2%80%8B the reader\n") { + t.Errorf("the headline does not carry the encoded runes:\n%s", raw) + } +} + +// TestRecordGroundsEncodesHiddenRunes: intent grounds are the same boundary. +func TestRecordGroundsEncodesHiddenRunes(t *testing.T) { + root := t.TempDir() + const rel = plannedDir + "/itd-10-alpha.md" + writeFile(t, root, rel, plannedUnlinked("itd-10", "alpha")) + g := mustGrounds(t, grounds.Pursued, "we expect a stamped ‮identity to survive rewording") + if _, err := RecordGrounds(root, "itd-10", g); err != nil { + t.Fatal(err) + } + raw := readIntent(t, root, rel) + if strings.Contains(raw, "‮") || !strings.Contains(raw, "stamped %E2%80%AEidentity") { + t.Fatalf("intent grounds did not encode the bidi override:\n%q", raw) + } +} diff --git a/internal/termsafe/encode_hidden_runes.go b/internal/termsafe/encode_hidden_runes.go index fc1912d7b..40ffa4a26 100644 --- a/internal/termsafe/encode_hidden_runes.go +++ b/internal/termsafe/encode_hidden_runes.go @@ -47,3 +47,29 @@ func EncodeHiddenRunes(s string) string { } return b.String() } + +// EncodeHiddenRunesBlock is EncodeHiddenRunes for multi-line prose bound for a +// committed record — a capture body, a resolution note, a press release. It +// encodes every rune EncodeHiddenRunes encodes EXCEPT the three that are the +// prose's own structure: the line feed, the tab, and a carriage return that is +// half of a CRLF pair. A bare carriage return is encoded, because it is the +// terminal overwrite a record must not carry (iss-2608301206073609). +// +// It is SanitizeBlock's counterpart at the record boundary: the render path masks, +// the record path encodes, and both keep the line structure the prose was +// written with. +func EncodeHiddenRunesBlock(s string) string { + lines := strings.Split(s, "\n") + for i, l := range lines { + cr := "" + if i < len(lines)-1 && strings.HasSuffix(l, "\r") { + l, cr = l[:len(l)-1], "\r" + } + cells := strings.Split(l, "\t") + for j, c := range cells { + cells[j] = EncodeHiddenRunes(c) + } + lines[i] = strings.Join(cells, "\t") + cr + } + return strings.Join(lines, "\n") +} diff --git a/internal/termsafe/encode_hidden_runes_block_test.go b/internal/termsafe/encode_hidden_runes_block_test.go new file mode 100644 index 000000000..1cb060d34 --- /dev/null +++ b/internal/termsafe/encode_hidden_runes_block_test.go @@ -0,0 +1,27 @@ +package termsafe + +import "testing" + +// TestEncodeHiddenRunesBlockKeepsLineStructure: a record body is multi-line +// prose, so the block form encodes every rune EncodeHiddenRunes encodes EXCEPT +// the three that are the body's own structure — the line feed, the tab, and a +// carriage return that is half of a CRLF pair. Everything else hidden is +// percent-encoded losslessly (iss-2608301206073609). +func TestEncodeHiddenRunesBlockKeepsLineStructure(t *testing.T) { + cases := []struct{ name, in, want string }{ + {"clean text is untouched", "a line\n\tindented\nlast", "a line\n\tindented\nlast"}, + {"bidi override is encoded", "x‮y", "x%E2%80%AEy"}, + {"zero-width space is encoded", "a​b", "a%E2%80%8Bb"}, + {"C1 and DEL are encoded", "a\u0085b\x7fc", "a%C2%85b%7Fc"}, + {"an escape is encoded, the newline kept", "a\x1b[2J\nb", "a%1B[2J\nb"}, + {"CRLF is kept, a bare CR is encoded", "a\r\nb\rc", "a\r\nb%0Dc"}, + {"invalid UTF-8 is encoded raw", "a\xffb", "a%FFb"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := EncodeHiddenRunesBlock(c.in); got != c.want { + t.Errorf("EncodeHiddenRunesBlock(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} From 089c861a65c6ef9eae0e4548800bbc064f8f932d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:58:34 +0100 Subject: [PATCH 05/36] =?UTF-8?q?chore:=20resolve=20iss-2608301206073609?= =?UTF-8?q?=20=E2=80=94=20hidden=20runes=20encoded=20on=20record=20write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2608301206073609 Assisted-by: Claude:claude-opus-5-5 --- ...nes-reach-committed-records-verbatim-because-termsa.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2608301206073609-hidden-runes-reach-committed-records-verbatim-because-termsa.md (74%) diff --git a/.abcd/work/issues/open/iss-2608301206073609-hidden-runes-reach-committed-records-verbatim-because-termsa.md b/.abcd/work/issues/resolved/iss-2608301206073609-hidden-runes-reach-committed-records-verbatim-because-termsa.md similarity index 74% rename from .abcd/work/issues/open/iss-2608301206073609-hidden-runes-reach-committed-records-verbatim-because-termsa.md rename to .abcd/work/issues/resolved/iss-2608301206073609-hidden-runes-reach-committed-records-verbatim-because-termsa.md index 68de70c1a..00b928aa3 100644 --- a/.abcd/work/issues/open/iss-2608301206073609-hidden-runes-reach-committed-records-verbatim-because-termsa.md +++ b/.abcd/work/issues/resolved/iss-2608301206073609-hidden-runes-reach-committed-records-verbatim-because-termsa.md @@ -7,6 +7,10 @@ category: "security" source: "user-observation" found_during: "itd-179-round-2-security" found_at: "internal/termsafe/termsafe.go" +resolution: "Hidden runes are percent-encoded at the record-write boundary: termsafe.EncodeHiddenRunesBlock for multi-line text and EncodeHiddenRunes for a single line, applied after redaction to the capture body, found_at and found_during, the resolution and wontfix note, every grounds entry through grounds.New and grounds.NewDerived, and the intent draft title, press release and body at CreateDraft." +impact: fix +resolved_by: + commit: "166d35ac" --- hidden runes reach committed records verbatim because termsafe.EncodeHiddenRunes is not applied at the record-write boundary @@ -38,3 +42,7 @@ radius (every record-writing path), which is why it is captured open rather than folded into itd-179. Note the interaction: applying EncodeHiddenRunes at the grounds boundary would also close the invisibility half of iss-2608301206034359. + +## Grounds + +- pursued: no committed ledger or intent record can carry a bidi override or zero-width rune verbatim again; a record written through any of these verbs still holding one raw would show it wrong From 723976416e99485e171ab0a759738961ab3d580d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:58:35 +0100 Subject: [PATCH 06/36] =?UTF-8?q?chore:=20resolve=20iss-2608301244450106?= =?UTF-8?q?=20=E2=80=94=20derived=20wontfix=20grounds=20validated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2608301244450106 Assisted-by: Claude:claude-opus-5-5 --- ...-reason-derived-grounds-value-skips-validatetext-so.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2608301244450106-a-wontfix-reason-derived-grounds-value-skips-validatetext-so.md (68%) diff --git a/.abcd/work/issues/open/iss-2608301244450106-a-wontfix-reason-derived-grounds-value-skips-validatetext-so.md b/.abcd/work/issues/resolved/iss-2608301244450106-a-wontfix-reason-derived-grounds-value-skips-validatetext-so.md similarity index 68% rename from .abcd/work/issues/open/iss-2608301244450106-a-wontfix-reason-derived-grounds-value-skips-validatetext-so.md rename to .abcd/work/issues/resolved/iss-2608301244450106-a-wontfix-reason-derived-grounds-value-skips-validatetext-so.md index 0b4b0e5c4..64182d21d 100644 --- a/.abcd/work/issues/open/iss-2608301244450106-a-wontfix-reason-derived-grounds-value-skips-validatetext-so.md +++ b/.abcd/work/issues/resolved/iss-2608301244450106-a-wontfix-reason-derived-grounds-value-skips-validatetext-so.md @@ -7,6 +7,10 @@ category: "bug" source: "user-observation" found_during: "itd-179-round-3-builder" found_at: "internal/core/capture/grounds.go" +resolution: "The grounds a wontfix derives from its reason go through grounds.NewDerived, which applies the control-character check ValidateText applies to supplied grounds (held once as validateControl) and leaves only the substance floor off, so a control character in a wontfix_reason is refused at the grounds boundary before any write." +impact: fix +resolved_by: + commit: "166d35ac" --- a wontfix reason-derived grounds value skips ValidateText so a control character in wontfix_reason still reaches yamlScalar @@ -27,3 +31,7 @@ its site and its message differ. Left open for the facilitator. Closing it would mean routing derived grounds through the same validator as supplied grounds, which is a small consolidation in the direction the one-canonical-primitive rule already points. + +## Grounds + +- pursued: a wontfix reason carrying a control character is refused as a grounds refusal naming the rune; a refusal arriving from the frontmatter serialiser instead would show it wrong From dd61abc69233c03bfd22e5f83bd23e7dd8b344f3 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:05:26 +0100 Subject: [PATCH 07/36] fix(capture): resolve every ledger write and the lock inside an os.Root The ledger's writes were absolute-path operations, each guarded by its own Lstat or O_NOFOLLOW check, which left the window between a check and the write it licensed to a local racer: an ancestor swapped for a symlink in that window carried a directory or a record out of the checkout. Every ledger write now resolves inside an os.Root opened on the checkout (ledgerroot.go): the directory walk creates through root.Mkdir, the placeholder is an exclusive no-follow create in the root, the commit, the transition, the link, promote and migrate stamps write through WriteFileAtomicPreserveModeInRoot, the removals go through root.Remove, and the allocator lock opens through the new fsutil.WithFileLockIn. That lock open uses the plain-open / exclusive-create sequence AppendLineIn already uses, because a non-exclusive openat(O_CREAT) races to ENOENT on darwin. The per-segment Lstat walk still refuses a committed symlink by name. A test seam (ledgerRaceHook) drives the swap at the mkdir and write windows. reading.go's own directory provisioning keeps its path-operand call: safeMkdirLeaf stays path-based (now resolved inside a root on the parent) so that file is untouched. Refs: iss-2609012037143368 Assisted-by: Claude:claude-opus-5-5 --- internal/core/capture/alloc.go | 137 +++++++++++++++-------- internal/core/capture/alloc_test.go | 2 +- internal/core/capture/ledgerroot.go | 93 +++++++++++++++ internal/core/capture/ledgerroot_test.go | 105 +++++++++++++++++ internal/core/capture/link.go | 2 +- internal/core/capture/migrate.go | 8 +- internal/core/capture/promote.go | 6 +- internal/core/capture/workflow.go | 24 ++-- internal/fsutil/flock.go | 67 +++++++++++ internal/fsutil/flock_in_test.go | 49 ++++++++ 10 files changed, 432 insertions(+), 61 deletions(-) create mode 100644 internal/core/capture/ledgerroot.go create mode 100644 internal/core/capture/ledgerroot_test.go create mode 100644 internal/fsutil/flock_in_test.go diff --git a/internal/core/capture/alloc.go b/internal/core/capture/alloc.go index 46caca4a0..6b2355241 100644 --- a/internal/core/capture/alloc.go +++ b/internal/core/capture/alloc.go @@ -33,6 +33,13 @@ var lockTimeout = 5 * time.Second // interleaving deterministically. var beforeOrphanRemoveHook func(cand string) +// ledgerRaceHook, when non-nil, fires inside the windows a local racer could use +// to redirect a ledger write: "mkdir" between one directory's check and its +// creation, and "write" between a record's last re-read and its write. rel is +// the repo-relative path about to be touched. A test-only seam (nil in +// production) for iss-2609012037143368's detector. +var ledgerRaceHook func(stage, rel string) + // ensureLedgerDirs provisions issuesRoot and its status sub-directories, refusing // symlinked leaves AND symlinked ancestors. The list is issueschema.StatusDirs — // the same value the readers scan and the deterministic gates scope to, so a @@ -74,10 +81,11 @@ func ensureLedgerDirs(repoRoot, issuesRoot string) error { // ledger is a state the readers already tolerate, and nothing can hide behind a // directory that is not there. // -// The walk is modelled on memory.memoryDir (the GHSA-72rp fix) and carries the -// same residue: the window between one segment's Lstat and its Mkdir is a -// local-racer TOCTOU, closed only by opening the store as an os.Root, which is -// the package-wide follow-up iss-2609012037143368 records. +// The walk is modelled on memory.memoryDir (the GHSA-72rp fix). Its residue — +// the window between one segment's Lstat and its Mkdir, a local-racer TOCTOU — +// is closed by creating through an os.Root on the containment base +// (iss-2609012037143368): a segment swapped in that window cannot carry the +// store out of the checkout. // // An issuesRoot outside repoRoot is an operator-typed operand with no boundary // to walk from: group 1 is skipped (with create, its parent is provisioned with @@ -106,37 +114,71 @@ func ledgerDirs(repoRoot, issuesRoot string, create bool) error { for _, sub := range issueschema.StatusDirs { dirs = append(dirs, filepath.Join(issuesRoot, sub)) } - for _, dir := range dirs { - if create { - if err := safeMkdirLeaf(dir); err != nil { + if !create { + for _, dir := range dirs { + if err := refuseSymlinkedDir(dir); err != nil { return err } - continue } - if err := refuseSymlinkedDir(dir); err != nil { + return nil + } + // Created through ONE os.Root on the containment base (ledgerroot.go), so a + // segment swapped for a symlink between its check and the next mkdir cannot + // carry the store out of the checkout (iss-2609012037143368). + base := ledgerBase(repoRoot, issuesRoot) + root, err := os.OpenRoot(base) + if err != nil { + return err + } + defer root.Close() + for _, dir := range dirs { + rel, err := containedRel(base, dir) + if err != nil { + return err + } + if err := mapEscape(safeMkdirLeafIn(root, rel), dir); err != nil { return err } } return nil } -// safeMkdirLeaf creates target if absent, then insists (via Lstat) that the -// result is a real directory and not a symlink. +// safeMkdirLeaf creates target if absent and insists it is a real directory, +// resolved inside an os.Root on target's parent — the path-operand form, for a +// caller holding an absolute path and no ledger roots (the reading families' +// directories). func safeMkdirLeaf(target string) error { - fi, err := os.Lstat(target) + parent := filepath.Dir(target) + root, err := os.OpenRoot(parent) + if err != nil { + return fmt.Errorf("%w: cannot open %s: %v", ErrPathUnsafe, parent, err) + } + defer root.Close() + return safeMkdirLeafIn(root, filepath.Base(target)) +} + +// safeMkdirLeafIn creates rel inside root if absent, then insists (via Lstat) +// that the result is a real directory and not a symlink. The Lstat refuses a +// committed symlink by name; the root refuses one swapped in afterwards that +// points outside it. +func safeMkdirLeafIn(root *os.Root, rel string) error { + fi, err := root.Lstat(rel) + if ledgerRaceHook != nil { + ledgerRaceHook("mkdir", rel) + } if os.IsNotExist(err) { - if mkErr := os.Mkdir(target, 0o755); mkErr != nil && !os.IsExist(mkErr) { - return fmt.Errorf("%w: mkdir failed for %s: %v", ErrPathUnsafe, target, mkErr) + if mkErr := root.Mkdir(rel, 0o755); mkErr != nil && !os.IsExist(mkErr) { + return fmt.Errorf("%w: mkdir failed for %s: %v", ErrPathUnsafe, rel, mkErr) } - fi, err = os.Lstat(target) + fi, err = root.Lstat(rel) if err != nil { - return fmt.Errorf("%w: leaf disappeared after mkdir: %s", ErrPathUnsafe, target) + return fmt.Errorf("%w: leaf disappeared after mkdir: %s", ErrPathUnsafe, rel) } } else if err != nil { - return fmt.Errorf("%w: lstat failed for %s: %v", ErrPathUnsafe, target, err) + return fmt.Errorf("%w: lstat failed for %s: %v", ErrPathUnsafe, rel, err) } if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { - return fmt.Errorf("%w: not a real directory: %s", ErrPathUnsafe, target) + return fmt.Errorf("%w: not a real directory: %s", ErrPathUnsafe, rel) } return nil } @@ -151,8 +193,12 @@ func withLedgerLock(repoRoot, issuesRoot string, fn func() error) error { if err := ensureLedgerDirs(repoRoot, issuesRoot); err != nil { return err } + // The lock file is opened inside the ledger's os.Root, like every other + // ledger write (iss-2609012037143368). lockPath := filepath.Join(issuesRoot, lockFilename) - err := fsutil.WithFileLock(lockPath, lockTimeout, fn) + err := withContainedRoot(ledgerBase(repoRoot, issuesRoot), lockPath, func(root *os.Root, rel string) error { + return fsutil.WithFileLockIn(root, rel, lockTimeout, fn) + }) switch { case errors.Is(err, fsutil.ErrLockContention): return fmt.Errorf("%w: could not acquire allocator lock within %s", ErrAllocatorContention, lockTimeout) @@ -213,14 +259,12 @@ func reservePath(repoRoot, issuesRoot, slug, forceID string) (string, string, er return fmt.Errorf("%w: %s already exists in the ledger", ErrDuplicateIssueID, forceID) } target := filepath.Join(issuesRoot, "open", forceID+"-"+slug+".md") - fd, cErr := createPlaceholder(target) - if cErr != nil { + if cErr := createPlaceholder(repoRoot, issuesRoot, target); cErr != nil { if os.IsExist(cErr) { return fmt.Errorf("%w: %s appeared between scan and create", ErrDuplicateIssueID, forceID) } return cErr } - syscall.Close(fd) resID, resTarget = forceID, target return nil } @@ -234,14 +278,12 @@ func reservePath(repoRoot, issuesRoot, slug, forceID string) (string, string, er continue } target := filepath.Join(issuesRoot, "open", issID+"-"+slug+".md") - fd, cErr := createPlaceholder(target) - if cErr != nil { + if cErr := createPlaceholder(repoRoot, issuesRoot, target); cErr != nil { if os.IsExist(cErr) { continue } return cErr } - syscall.Close(fd) resID, resTarget = issID, target return nil } @@ -253,22 +295,23 @@ func reservePath(repoRoot, issuesRoot, slug, forceID string) (string, string, er return resID, resTarget, nil } -// createPlaceholder does an O_EXCL|O_NOFOLLOW create of the placeholder file. -func createPlaceholder(target string) (int, error) { - fd, err := syscall.Open(target, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY|syscall.O_NOFOLLOW, 0o644) - if err != nil { - if err == syscall.ELOOP { - return -1, fmt.Errorf("%w: placeholder path is a symlink: %s", ErrPathUnsafe, target) - } - if err == syscall.EEXIST { - if fi, lerr := os.Lstat(target); lerr == nil && fi.Mode()&os.ModeSymlink != 0 { - return -1, fmt.Errorf("%w: placeholder path is a symlink: %s", ErrPathUnsafe, target) +// createPlaceholder does an exclusive, no-follow create of the zero-byte +// placeholder inside the ledger's os.Root. An existing entry is os.ErrExist, +// unless it is a symlink, which is ErrPathUnsafe. +func createPlaceholder(repoRoot, issuesRoot, target string) error { + return withContainedRoot(ledgerBase(repoRoot, issuesRoot), target, func(root *os.Root, rel string) error { + f, err := root.OpenFile(rel, os.O_CREATE|os.O_EXCL|os.O_WRONLY|syscall.O_NOFOLLOW, 0o644) + if err != nil { + if errors.Is(err, os.ErrExist) { + if fi, lerr := root.Lstat(rel); lerr == nil && fi.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%w: placeholder path is a symlink: %s", ErrPathUnsafe, target) + } + return os.ErrExist } - return -1, os.ErrExist + return err } - return -1, err - } - return fd, nil + return f.Close() + }) } // issPresent reports whether issID exists in any status dir. It walks @@ -294,8 +337,8 @@ func issPresent(issuesRoot, issID string) bool { // cancelReservation removes a zero-byte placeholder idempotently. It refuses a // symlinked or non-empty target (real content is the caller's transactional -// responsibility). -func cancelReservation(path string) error { +// responsibility). The removal is resolved inside the ledger's os.Root. +func cancelReservation(repoRoot, issuesRoot, path string) error { fi, err := os.Lstat(path) if os.IsNotExist(err) { return nil @@ -312,15 +355,13 @@ func cancelReservation(path string) error { if fi.Size() != 0 { return fmt.Errorf("refusing to cancel non-empty placeholder (%d bytes): %s", fi.Size(), path) } - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return err - } - return nil + return removeLedgerFile(repoRoot, issuesRoot, path) } // cleanOrphanPlaceholders sweeps zero-byte iss-N placeholders older than the -// threshold from open/. Tolerates a virgin ledger. Refuses symlinked roots. -func cleanOrphanPlaceholders(issuesRoot string) error { +// threshold from open/. Tolerates a virgin ledger. Refuses symlinked roots. The +// unlink is resolved inside the ledger's os.Root. +func cleanOrphanPlaceholders(repoRoot, issuesRoot string) error { fi, err := os.Lstat(issuesRoot) if os.IsNotExist(err) { return nil @@ -374,7 +415,7 @@ func cleanOrphanPlaceholders(issuesRoot string) error { if beforeOrphanRemoveHook != nil { beforeOrphanRemoveHook(cand) } - os.Remove(cand) + _ = removeLedgerFile(repoRoot, issuesRoot, cand) } return nil } diff --git a/internal/core/capture/alloc_test.go b/internal/core/capture/alloc_test.go index 26190b8b6..cf261dd7e 100644 --- a/internal/core/capture/alloc_test.go +++ b/internal/core/capture/alloc_test.go @@ -62,7 +62,7 @@ func TestCleanOrphanPlaceholdersStillSweepsAgedOrphan(t *testing.T) { if err := os.Chtimes(orphan, old, old); err != nil { t.Fatal(err) } - if err := cleanOrphanPlaceholders(ir); err != nil { + if err := cleanOrphanPlaceholders(repo, ir); err != nil { t.Fatal(err) } if _, err := os.Lstat(orphan); !os.IsNotExist(err) { diff --git a/internal/core/capture/ledgerroot.go b/internal/core/capture/ledgerroot.go new file mode 100644 index 000000000..2e9de345a --- /dev/null +++ b/internal/core/capture/ledgerroot.go @@ -0,0 +1,93 @@ +package capture + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// ledgerroot.go is the ledger's containment scope (iss-2609012037143368). Every +// ledger write — a directory, the allocator lock, a placeholder, a record, a +// removal — resolves its path INSIDE an os.Root opened on the checkout, never +// as an absolute path. The per-segment Lstat walk (ledgerDirs) still refuses a +// committed symlink by name; the root is what closes the window between that +// check and the write it licenses, because a directory swapped for a symlink in +// that window cannot carry the write out of the root. + +// ledgerBase is the directory a ledger's writes are contained in: the checkout +// when the ledger sits inside it (every front door), otherwise the ledger's own +// parent, for an operator-typed ledger operand that has no checkout around it. +func ledgerBase(repoRoot, issuesRoot string) string { + if fsutil.PathWithin(issuesRoot, repoRoot, false) { + return repoRoot + } + return filepath.Dir(issuesRoot) +} + +// containedRel maps abs to the slash path an os.Root at base resolves, refusing +// a path that is not under base. +func containedRel(base, abs string) (string, error) { + rel, err := filepath.Rel(base, abs) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return "", fmt.Errorf("%w: %s is outside the ledger's containment root", ErrPathUnsafe, abs) + } + return filepath.ToSlash(rel), nil +} + +// withContainedRoot opens an os.Root on base and runs fn with it and abs's path +// relative to it. +func withContainedRoot(base, abs string, fn func(root *os.Root, rel string) error) error { + rel, err := containedRel(base, abs) + if err != nil { + return err + } + root, err := os.OpenRoot(base) + if err != nil { + return err + } + defer root.Close() + return mapEscape(fn(root, rel), abs) +} + +// mapEscape reports an os.Root refusal as the ledger's own path-unsafe +// sentinel, so every caller that already tests ErrPathUnsafe sees one. The os +// package does not export its escape error, so its message is matched. +func mapEscape(err error, abs string) error { + if err == nil || errors.Is(err, ErrPathUnsafe) || !strings.Contains(err.Error(), "path escapes from parent") { + return err + } + return fmt.Errorf("%w: %s resolves outside the ledger's containment root: %v", ErrPathUnsafe, abs, err) +} + +// writeContained writes data to abs atomically inside an os.Root on base, +// keeping the file's existing mode. +func writeContained(base, abs string, data []byte) error { + return withContainedRoot(base, abs, func(root *os.Root, rel string) error { + return fsutil.WriteFileAtomicPreserveModeInRoot(root, rel, data) + }) +} + +// removeContained removes abs inside an os.Root on base; an absent file is not +// an error, matching os.Remove as its callers used it. +func removeContained(base, abs string) error { + return withContainedRoot(base, abs, func(root *os.Root, rel string) error { + if err := root.Remove(rel); err != nil && !os.IsNotExist(err) { + return err + } + return nil + }) +} + +// writeLedgerFile is writeContained for a path in this ledger. +func writeLedgerFile(repoRoot, issuesRoot, abs string, data []byte) error { + return writeContained(ledgerBase(repoRoot, issuesRoot), abs, data) +} + +// removeLedgerFile is removeContained for a path in this ledger. +func removeLedgerFile(repoRoot, issuesRoot, abs string) error { + return removeContained(ledgerBase(repoRoot, issuesRoot), abs) +} diff --git a/internal/core/capture/ledgerroot_test.go b/internal/core/capture/ledgerroot_test.go new file mode 100644 index 000000000..2c9d01f31 --- /dev/null +++ b/internal/core/capture/ledgerroot_test.go @@ -0,0 +1,105 @@ +package capture + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// swapAbcdForSymlink replaces repo/.abcd with a symlink to target, moving the +// real directory aside — the move a local racer makes in the window between a +// ledger check and the write it licensed. +func swapAbcdForSymlink(t *testing.T, repo, target string) { + t.Helper() + if err := os.Rename(filepath.Join(repo, ".abcd"), filepath.Join(repo, ".abcd-moved")); err != nil { + t.Fatalf("move .abcd aside: %v", err) + } + if err := os.Symlink(target, filepath.Join(repo, ".abcd")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } +} + +// walkFiles lists every regular file under dir, for asserting a write never +// landed there. +func walkFiles(t *testing.T, dir string) []string { + t.Helper() + var out []string + _ = filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error { + if err == nil && fi.Mode().IsRegular() { + out = append(out, p) + } + return nil + }) + return out +} + +// TestLedgerMkdirCannotBeRedirectedOutsideTheCheckout is iss-2609012037143368's +// mkdir half. The directory walk checks each segment with Lstat and then creates +// the next; a racer who swaps an already-checked ancestor for a symlink in +// between used to have the next os.Mkdir create the ledger outside the checkout. +// Created through an os.Root on the checkout, the swap cannot escape it. +func TestLedgerMkdirCannotBeRedirectedOutsideTheCheckout(t *testing.T) { + repo, ir := ledger(t) + outside := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + swapped := false + ledgerRaceHook = func(stage, rel string) { + if stage == "mkdir" && !swapped && filepath.Base(rel) == "work" { + swapped = true + swapAbcdForSymlink(t, repo, outside) + } + } + t.Cleanup(func() { ledgerRaceHook = nil }) + + _, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "a finding", + Severity: SeverityMinor, Category: "bug", Source: "manual-test", Slug: "raced", FoundDuring: "t"}) + if !swapped { + t.Fatal("the race hook never fired at the .abcd/work mkdir") + } + if err == nil { + t.Fatal("a capture whose ledger ancestor was swapped for an outside symlink succeeded") + } + if entries, _ := os.ReadDir(outside); len(entries) != 0 { + t.Fatalf("the ledger walk created %d entr(ies) outside the checkout: %v", len(entries), entries) + } +} + +// TestLedgerRecordWriteCannotBeRedirectedOutsideTheCheckout is the write half: +// between the commit's last re-read of its placeholder and the write, a swapped +// ancestor used to carry the record out of the checkout. +func TestLedgerRecordWriteCannotBeRedirectedOutsideTheCheckout(t *testing.T) { + repo, ir := ledger(t) + if _, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "a first finding", + Severity: SeverityMinor, Category: "bug", Source: "manual-test", Slug: "first", FoundDuring: "t"}); err != nil { + t.Fatal(err) + } + outside := t.TempDir() + if err := os.MkdirAll(filepath.Join(outside, "work", "issues", "open"), 0o755); err != nil { + t.Fatal(err) + } + swapped := false + ledgerRaceHook = func(stage, rel string) { + if stage == "write" && !swapped { + swapped = true + swapAbcdForSymlink(t, repo, outside) + } + } + t.Cleanup(func() { ledgerRaceHook = nil }) + + _, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "a second finding", + Severity: SeverityMinor, Category: "bug", Source: "manual-test", Slug: "second", FoundDuring: "t"}) + if !swapped { + t.Fatal("the race hook never fired before the record write") + } + if err == nil { + t.Fatal("a capture whose ledger ancestor was swapped at the write succeeded") + } + for _, f := range walkFiles(t, outside) { + if strings.HasSuffix(f, ".md") || strings.Contains(filepath.Base(f), "abcd-tmp") { + t.Fatalf("the record write landed outside the checkout: %s", f) + } + } +} diff --git a/internal/core/capture/link.go b/internal/core/capture/link.go index 82a0d0325..a493120dd 100644 --- a/internal/core/capture/link.go +++ b/internal/core/capture/link.go @@ -192,7 +192,7 @@ func Link(req LinkRequest) (LinkResult, error) { // In place, atomic — the file keeps its status directory. The write // happens under the same lock as the re-read, so no checksum window // exists between them. - if err := fsutil.WriteFileAtomicPreserveMode(src, []byte(newContent)); err != nil { + if err := writeLedgerFile(repoRoot, issuesRoot, src, []byte(newContent)); err != nil { return err } result = LinkResult{ID: req.ID, Path: src, BlockedBy: next} diff --git a/internal/core/capture/migrate.go b/internal/core/capture/migrate.go index 37faba913..11cc7271f 100644 --- a/internal/core/capture/migrate.go +++ b/internal/core/capture/migrate.go @@ -109,7 +109,13 @@ func Migrate(req MigrateRequest) (MigrateResult, error) { if err != nil { return fmt.Errorf("migrate %s: %w", r.rel, err) } - if err := fsutil.WriteFileAtomicPreserveMode(r.abs, []byte(updated)); err != nil { + // Resolved inside an os.Root (iss-2609012037143368): the ledger's own + // containment base for a ledger record, the checkout for an intent. + base := ledgerBase(repoRoot, issuesRoot) + if !fsutil.PathWithin(r.abs, issuesRoot, false) { + base = repoRoot + } + if err := writeContained(base, r.abs, []byte(updated)); err != nil { return fmt.Errorf("migrate %s: %w", r.rel, err) } } diff --git a/internal/core/capture/promote.go b/internal/core/capture/promote.go index 6a7d5149d..a4cdd2ed2 100644 --- a/internal/core/capture/promote.go +++ b/internal/core/capture/promote.go @@ -286,7 +286,8 @@ func Promote(req PromoteRequest) (PromoteResult, error) { // In place, atomic — the file keeps its status directory (promotion is // not resolution). The write happens under the same lock as the re-read, // so no checksum window exists between them. - write := fsutil.WriteFileAtomicPreserveMode + // Inside the ledger's os.Root (iss-2609012037143368). + write := func(p string, data []byte) error { return writeLedgerFile(repoRoot, issuesRoot, p, data) } if stampWriteHook != nil { write = stampWriteHook } @@ -572,7 +573,8 @@ func promoteReadingItem(repoRoot, issuesRoot string, req PromoteRequest) (Promot if err := validateReadingStrict(newFM); err != nil { return err } - write := fsutil.WriteFileAtomicPreserveMode + // Inside the ledger's os.Root (iss-2609012037143368). + write := func(p string, data []byte) error { return writeLedgerFile(repoRoot, issuesRoot, p, data) } if stampWriteHook != nil { write = stampWriteHook } diff --git a/internal/core/capture/workflow.go b/internal/core/capture/workflow.go index 9874ce703..d934fd26b 100644 --- a/internal/core/capture/workflow.go +++ b/internal/core/capture/workflow.go @@ -26,7 +26,7 @@ import ( // a commit's fill and delete a just-committed issue file. func mutationPreamble(repoRoot, issuesRoot string) error { if err := withLedgerLock(repoRoot, issuesRoot, func() error { - return cleanOrphanPlaceholders(issuesRoot) + return cleanOrphanPlaceholders(repoRoot, issuesRoot) }); err != nil { return err } @@ -115,7 +115,7 @@ func Capture(req CaptureRequest) (CaptureResult, error) { result, err := commitCapture(repoRoot, issuesRoot, req, issID, slugNorm, placeholder) if err != nil { - _ = cancelReservation(placeholder) + _ = cancelReservation(repoRoot, issuesRoot, placeholder) return CaptureResult{}, err } result.Redacted, result.Degraded = redacted, degraded @@ -219,7 +219,13 @@ func commitCapture(repoRoot, issuesRoot string, req CaptureRequest, issID, slug, if checksum != emptyChecksum { return fmt.Errorf("%w: placeholder %s changed since reservation", ErrChecksumMismatch, placeholder) } - if werr := fsutil.WriteFileAtomicPreserveMode(placeholder, []byte(content)); werr != nil { + if ledgerRaceHook != nil { + ledgerRaceHook("write", placeholder) + } + // Written inside the ledger's os.Root (iss-2609012037143368): an ancestor + // swapped since the re-read above cannot carry the record out of the + // checkout. + if werr := writeLedgerFile(repoRoot, issuesRoot, placeholder, []byte(content)); werr != nil { return werr } result = CaptureResult{ID: issID, Slug: slug, Path: placeholder, Status: StateOpen} @@ -532,7 +538,7 @@ func transition(repoRoot, issuesRoot, issID, verb, field, note string, extra []k return err } - if err := commitTransition(src, dst, newContent, checksum); err != nil { + if err := commitTransition(rr, ir, src, dst, newContent, checksum); err != nil { return err } result = TransitionResult{ID: issID, Path: dst, FromStatus: StateOpen, ToStatus: target, @@ -565,7 +571,7 @@ var removeSourceHook func(path string) error // dir, so a stranded copy could never again be transitioned without manual // repair. Rolling back restores the pre-call state (src present, dst absent) // so the caller can simply retry once the underlying failure clears. -func commitTransition(src, dst, newContent, expected string) error { +func commitTransition(repoRoot, issuesRoot, src, dst, newContent, expected string) error { _, current, err := readWithChecksum(src) if os.IsNotExist(err) { return fmt.Errorf("%w: %s move source missing", ErrTransitionConflict, src) @@ -576,15 +582,17 @@ func commitTransition(src, dst, newContent, expected string) error { if current != expected { return fmt.Errorf("%w: %s changed since it was read", ErrChecksumMismatch, src) } - if err := fsutil.WriteFileAtomicPreserveMode(dst, []byte(newContent)); err != nil { + // The write and both removals resolve inside the ledger's os.Root + // (iss-2609012037143368). + if err := writeLedgerFile(repoRoot, issuesRoot, dst, []byte(newContent)); err != nil { return err } - removeSrc := os.Remove + removeSrc := func(p string) error { return removeLedgerFile(repoRoot, issuesRoot, p) } if removeSourceHook != nil { removeSrc = removeSourceHook } if err := removeSrc(src); err != nil && !os.IsNotExist(err) { - if rbErr := os.Remove(dst); rbErr != nil && !os.IsNotExist(rbErr) { + if rbErr := removeLedgerFile(repoRoot, issuesRoot, dst); rbErr != nil && !os.IsNotExist(rbErr) { return fmt.Errorf("%w (rollback of %s also failed: %v)", err, dst, rbErr) } return err diff --git a/internal/fsutil/flock.go b/internal/fsutil/flock.go index 7cb22c7d2..42c8632a7 100644 --- a/internal/fsutil/flock.go +++ b/internal/fsutil/flock.go @@ -3,6 +3,7 @@ package fsutil import ( "errors" "fmt" + "os" "syscall" "time" ) @@ -92,3 +93,69 @@ func acquireFlock(fd int, timeout time.Duration) error { } } } + +// WithFileLockIn is WithFileLock with the lock file resolved INSIDE root: rel is +// a slash path relative to it, so an ancestor swapped for a symlink cannot carry +// the lock out of the containment scope between a caller's checks and the open. +// The leaf keeps WithFileLock's refusals: a symlink or a non-regular file at rel +// is ErrLockPathUnsafe, judged by Lstat and confirmed on the opened descriptor +// (os.SameFile), because os.Root follows a symlink that stays inside the root. +func WithFileLockIn(root *os.Root, rel string, timeout time.Duration, fn func() error) error { + f, err := openLockIn(root, rel) + if err != nil { + return err + } + defer f.Close() + fd := int(f.Fd()) + if err := acquireFlock(fd, timeout); err != nil { + return err + } + defer syscall.Flock(fd, syscall.LOCK_UN) + return fn() +} + +// openLockIn opens (creating when absent) the lock file at rel inside root and +// proves the descriptor is the regular, non-symlinked file that was checked. +func openLockIn(root *os.Root, rel string) (*os.File, error) { + pre, lerr := root.Lstat(rel) + switch { + case lerr == nil && pre.Mode()&os.ModeSymlink != 0: + return nil, fmt.Errorf("%w: lock path is a symlink: %s", ErrLockPathUnsafe, rel) + case lerr == nil && !pre.Mode().IsRegular(): + return nil, fmt.Errorf("%w: lock path is not a regular file: %s", ErrLockPathUnsafe, rel) + case lerr != nil && !errors.Is(lerr, os.ErrNotExist): + return nil, lerr + } + f, err := openOrCreateIn(root, rel, os.O_RDWR|syscall.O_NOFOLLOW, 0o644) + if err != nil { + return nil, err + } + st, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + if !st.Mode().IsRegular() || (lerr == nil && !os.SameFile(pre, st)) { + f.Close() + return nil, fmt.Errorf("%w: lock path changed or is not a regular file: %s", ErrLockPathUnsafe, rel) + } + return f, nil +} + +// openOrCreateIn opens rel inside root, creating it at perm when absent, using +// only the two opens that behave when several processes race to create the same +// file: a plain open, and an exclusive create exactly one racer wins. A single +// non-exclusive openat(O_CREAT) relative to a directory descriptor was observed +// on darwin to fail with ENOENT for some of several racers (openAppendIn's note), +// which here would fail a ledger verb on a lock file every racer was creating. +func openOrCreateIn(root *os.Root, rel string, flag int, perm os.FileMode) (*os.File, error) { + f, err := root.OpenFile(rel, flag, 0) + if err == nil || !errors.Is(err, os.ErrNotExist) { + return f, err + } + f, err = root.OpenFile(rel, flag|os.O_CREATE|os.O_EXCL, perm) + if err == nil || !errors.Is(err, os.ErrExist) { + return f, err + } + return root.OpenFile(rel, flag, 0) +} diff --git a/internal/fsutil/flock_in_test.go b/internal/fsutil/flock_in_test.go new file mode 100644 index 000000000..eb45487a4 --- /dev/null +++ b/internal/fsutil/flock_in_test.go @@ -0,0 +1,49 @@ +package fsutil + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +// TestWithFileLockInRefusesASymlinkedLeafAndAnEscape: the contained lock keeps +// WithFileLock's leaf refusal, and a lock path that resolves outside the root is +// refused by the root rather than opened (iss-2609012037143368). +func TestWithFileLockInRefusesASymlinkedLeafAndAnEscape(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "real"), nil, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("real", filepath.Join(dir, "linked.lock")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if err := os.Symlink(outside, filepath.Join(dir, "away")); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + ran := false + body := func() error { ran = true; return nil } + + if err := WithFileLockIn(root, "linked.lock", time.Second, body); !errors.Is(err, ErrLockPathUnsafe) { + t.Fatalf("symlinked leaf: want ErrLockPathUnsafe, got %v", err) + } + if err := WithFileLockIn(root, "away/x.lock", time.Second, body); err == nil { + t.Fatal("a lock path escaping the root was opened") + } + if entries, _ := os.ReadDir(outside); len(entries) != 0 { + t.Fatalf("the escaping lock created %v outside the root", entries) + } + if ran { + t.Fatal("fn ran under a refused lock") + } + if err := WithFileLockIn(root, "ok.lock", time.Second, body); err != nil || !ran { + t.Fatalf("a plain lock path: err=%v ran=%v", err, ran) + } +} From 20e3f39104da220094f3e6180ba1ed04dece4771 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:06:21 +0100 Subject: [PATCH 08/36] fix(capture): contain the reading and disposition record writes too The two ledger writes left in reading.go (a reading run's records and a disposition) go through the same os.Root helper as every other ledger write. Three lines; the file's symlink guard is untouched. Refs: iss-2609012037143368 Assisted-by: Claude:claude-opus-5-5 --- internal/core/capture/reading.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/core/capture/reading.go b/internal/core/capture/reading.go index 6069f8b96..5d859b551 100644 --- a/internal/core/capture/reading.go +++ b/internal/core/capture/reading.go @@ -254,7 +254,7 @@ func IngestReading(req IngestReadingRequest) (IngestReadingResult, error) { } for _, p := range pending { path := filepath.Join(runDir, p.id+".md") - if err := writeReadingRecord(path, []byte(p.content)); err != nil { + if err := writeReadingRecord(ledgerBase(repoRoot, issuesRoot), path, []byte(p.content)); err != nil { // Name what LANDED. A bare error leaves the caller unable to say // what is on disk, and a retry then mints fresh ids for the items // that already wrote — duplicating them inside the run directory. @@ -360,7 +360,7 @@ func Disposition(req DispositionRequest) (DispositionResult, error) { if err := refuseExistingRecord(path, id); err != nil { return err } - return fsutil.WriteFileAtomic(path, []byte(content), 0o644) + return writeContained(ledgerBase(repoRoot, issuesRoot), path, []byte(content)) }) if err != nil { return DispositionResult{}, err @@ -915,11 +915,11 @@ func recordIDs(records []ReadingRecordRef) []string { // deterministic mid-batch write failure, mirroring stampWriteHook in promote.go. var readingWriteHook func(path string, data []byte) error -func writeReadingRecord(path string, data []byte) error { +func writeReadingRecord(base, path string, data []byte) error { if readingWriteHook != nil { return readingWriteHook(path, data) } - return fsutil.WriteFileAtomic(path, data, 0o644) + return writeContained(base, path, data) } // refuseExistingRecord fails a write whose target is already taken. The id space From 997111d7739aabd7abcab8b7f1f92d829484a806 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:06:22 +0100 Subject: [PATCH 09/36] =?UTF-8?q?chore:=20resolve=20iss-2609012037143368?= =?UTF-8?q?=20=E2=80=94=20ledger=20writes=20contained=20in=20os.Root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609012037143368 Assisted-by: Claude:claude-opus-5-5 --- ...re-package-s-ledger-writes-are-absolute-path-operat.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609012037143368-the-capture-package-s-ledger-writes-are-absolute-path-operat.md (66%) diff --git a/.abcd/work/issues/open/iss-2609012037143368-the-capture-package-s-ledger-writes-are-absolute-path-operat.md b/.abcd/work/issues/resolved/iss-2609012037143368-the-capture-package-s-ledger-writes-are-absolute-path-operat.md similarity index 66% rename from .abcd/work/issues/open/iss-2609012037143368-the-capture-package-s-ledger-writes-are-absolute-path-operat.md rename to .abcd/work/issues/resolved/iss-2609012037143368-the-capture-package-s-ledger-writes-are-absolute-path-operat.md index 72aa14c00..3668044b2 100644 --- a/.abcd/work/issues/open/iss-2609012037143368-the-capture-package-s-ledger-writes-are-absolute-path-operat.md +++ b/.abcd/work/issues/resolved/iss-2609012037143368-the-capture-package-s-ledger-writes-are-absolute-path-operat.md @@ -9,6 +9,14 @@ found_during: "autonomous-run-2026-09-01" origin: researcher-authored production_mode: hand-written found_at: "internal/core/capture/alloc.go" +resolution: "Every capture-package ledger write resolves inside an os.Root on the checkout: directory creation through root.Mkdir, the placeholder as an exclusive no-follow create, record, transition, link, promote, migrate, reading and disposition writes through WriteFileAtomicPreserveModeInRoot, removals through root.Remove, and the allocator lock through the new fsutil.WithFileLockIn." +impact: internal +resolved_by: + commit: "dd61abc6" --- The capture package's ledger writes are absolute-path operations — createPlaceholder through syscall.Open, the .iss-alloc.lock flock path, fsutil.WriteFileAtomicPreserveMode for the commit and the stamps, os.Mkdir for the directories — each guarded by its own Lstat or O_NOFOLLOW check. The GHSA-865x fix adds a per-segment ancestor walk, which closes the committed-symlink case but leaves the lstat-to-mkdir window a local racer could use, the same window memory.memoryDir accepts. The durable closure is os.Root: fsutil already carries CreateExclusiveIn, WriteFileAtomicInRoot and ReadGuardedInRoot for exactly this class, and a store opened once as a root cannot be redirected between a check and a write. Converting the capture store to it is a package-wide refactor of every write site and the lock path, not a one-site fix, and it is the shape a structural record-store containment rule (iss-2608301308367566) would want every store to take. Recorded as the follow-up the advisory fix does not attempt. + +## Grounds + +- pursued: a directory or record swapped for an outside symlink between a ledger check and its write can no longer carry the write out of the checkout; a race-hook test landing anything outside would show it wrong From 93bea0dff8eb4ae647de8d12aeddece891130bd6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:07:33 +0100 Subject: [PATCH 10/36] fix(capture): delete the ledger-root marker walk; discovery is git's answer discoverRepoRoot fell back, where git would not answer, to walking upward and accepting any directory whose .git entry merely existed, with neither the shape check nor the ownership gate the rules-root resolver grew. It now delegates to gitutil.CheckoutRoot, the isolated rev-parse the front doors already resolve through, and returns no root when git names none. Nothing needed the walk: every front door resolves through LedgerRoot, which refuses the same states. Refs: iss-2609090947359464 Assisted-by: Claude:claude-opus-5-5 --- internal/core/capture/roots.go | 52 ++++++++++--------------- internal/core/capture/roots_env_test.go | 25 ++++++++++++ 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/internal/core/capture/roots.go b/internal/core/capture/roots.go index 22702e68e..d39885162 100644 --- a/internal/core/capture/roots.go +++ b/internal/core/capture/roots.go @@ -5,16 +5,15 @@ import ( "encoding/hex" "errors" "fmt" - "github.com/intentdriven/abcd/internal/core/recordid" "io/fs" "os" - "os/exec" "path/filepath" "regexp" "strings" "syscall" "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" ) @@ -100,42 +99,31 @@ const LedgerName = "the issue ledger" // reported success with a repo-relative path that looked ordinary // (iss-2609090951291524). // -// The resolution deliberately does not fall through to discoverRepoRoot's marker -// walk, which accepts any directory merely carrying the name and grew neither -// the shape check nor the ownership gate its rules-root sibling has -// (iss-2609090947359464). That walk is unreachable in shipped code, and routing -// the front doors through it would be the one change that makes it live. +// discoverRepoRoot, the core's own discovery for a request that names no root, +// asks the same question through the same function, so the two cannot come to +// disagree about which directory is a checkout (iss-2609090947359464). func LedgerRoot(cwd string) (string, error) { return gitutil.CheckoutRoot(cwd, LedgerName) } -// discoverRepoRoot returns the git worktree root containing start, or "". +// discoverRepoRoot returns the git worktree root containing start, or "" when +// git will not name one. +// +// It is git's answer or no answer, through gitutil.CheckoutRoot — the same +// isolated `rev-parse --show-toplevel` the front doors resolve through, so an +// inherited GIT_WORK_TREE or GIT_DIR cannot redirect it. It used to fall back to +// walking upward and accepting any directory whose .git entry merely existed, +// with neither the shape check nor the ownership gate the rules-root resolver +// grew: an empty marker planted in a shared ancestor, or a repository another +// uid laid there, bounded the ledger root (iss-2609090947359464). The walk is +// deleted rather than hardened because nothing needs it: every front door +// resolves through LedgerRoot, which refuses the same states outright. func discoverRepoRoot(start string) string { - cmd := exec.Command("git", "rev-parse", "--show-toplevel") - cmd.Dir = start - // Isolate: `rev-parse --show-toplevel` honours an inherited GIT_WORK_TREE/GIT_DIR - // over cmd.Dir, so without scrubbing an inherited value redirects repo-root - // discovery at a DIFFERENT tree — and the derived issuesRoot then reads and - // writes the ledger under an attacker-chosen path. Repo discovery needs no - // global config, so full isolation is safe. - cmd.Env = gitutil.IsolatedEnv() - out, err := cmd.Output() - if err == nil { - if root := strings.TrimSpace(string(out)); root != "" { - return root - } - } - dir := start - for { - if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { - return dir - } - parent := filepath.Dir(dir) - if parent == dir { - return "" - } - dir = parent + root, err := gitutil.CheckoutRoot(start, LedgerName) + if err != nil { + return "" } + return root } var reNonSlug = regexp.MustCompile(`[^a-z0-9]+`) diff --git a/internal/core/capture/roots_env_test.go b/internal/core/capture/roots_env_test.go index f2064c4e4..fef4e9ea0 100644 --- a/internal/core/capture/roots_env_test.go +++ b/internal/core/capture/roots_env_test.go @@ -1,6 +1,7 @@ package capture import ( + "os" "os/exec" "path/filepath" "testing" @@ -32,3 +33,27 @@ func TestDiscoverRepoRootIgnoresInheritedWorkTree(t *testing.T) { t.Errorf("discoverRepoRoot(%q) = %q under inherited GIT_WORK_TREE; want the real repo root %q (discovery was redirected)", repo, got, repoResolved) } } + +// TestDiscoverRepoRootNeverAcceptsAMarkerGitWillNotAnswerFor is +// iss-2609090947359464's detector. Where git cannot name a repository, the +// fallback used to walk upward and accept any directory whose .git entry merely +// existed — an empty marker planted in a shared ancestor, or a real repository +// another uid laid there, bounded the ledger root with neither the shape check +// nor the ownership gate the rules-root resolver grew. The walk is gone: +// discovery is git's answer or no answer, so neither plant resolves a root. +func TestDiscoverRepoRootNeverAcceptsAMarkerGitWillNotAnswerFor(t *testing.T) { + shared := t.TempDir() + if err := os.Mkdir(filepath.Join(shared, ".git"), 0o755); err != nil { + t.Fatal(err) + } + below := filepath.Join(shared, "a", "b") + if err := os.MkdirAll(below, 0o755); err != nil { + t.Fatal(err) + } + if got := discoverRepoRoot(below); got != "" { + t.Fatalf("discoverRepoRoot accepted the empty marker at %q as a repository root", got) + } + if _, _, err := resolveRoots("", filepath.Join(below, "issues")); err == nil { + t.Fatal("resolveRoots resolved a ledger root from an empty marker git will not answer for") + } +} From 7e562bdb031a406134237b6f99ac71d4b02188f8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:07:34 +0100 Subject: [PATCH 11/36] =?UTF-8?q?chore:=20resolve=20iss-2609090947359464?= =?UTF-8?q?=20=E2=80=94=20ledger-root=20walk=20deleted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609090947359464 Assisted-by: Claude:claude-opus-5-5 --- ...esolver-is-the-unhardened-sibling-of-the-rules-root.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609090947359464-the-ledger-root-resolver-is-the-unhardened-sibling-of-the-rules-root.md (88%) diff --git a/.abcd/work/issues/open/iss-2609090947359464-the-ledger-root-resolver-is-the-unhardened-sibling-of-the-rules-root.md b/.abcd/work/issues/resolved/iss-2609090947359464-the-ledger-root-resolver-is-the-unhardened-sibling-of-the-rules-root.md similarity index 88% rename from .abcd/work/issues/open/iss-2609090947359464-the-ledger-root-resolver-is-the-unhardened-sibling-of-the-rules-root.md rename to .abcd/work/issues/resolved/iss-2609090947359464-the-ledger-root-resolver-is-the-unhardened-sibling-of-the-rules-root.md index a95ddc4b5..6aa733e78 100644 --- a/.abcd/work/issues/open/iss-2609090947359464-the-ledger-root-resolver-is-the-unhardened-sibling-of-the-rules-root.md +++ b/.abcd/work/issues/resolved/iss-2609090947359464-the-ledger-root-resolver-is-the-unhardened-sibling-of-the-rules-root.md @@ -10,6 +10,10 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/capture/roots.go" related_issues: ["iss-2609090951291524"] +resolution: "The marker walk is deleted: discoverRepoRoot delegates to gitutil.CheckoutRoot and resolves no root where git names none, so an empty .git marker or a foreign-owned repository in an ancestor can no longer bound the ledger root; a test pins the empty-marker case through discoverRepoRoot and resolveRoots." +impact: internal +resolved_by: + commit: "93bea0df" --- discoverRepoRoot is the unhardened twin of the rules-root resolver that was hardened in the same batch. Its git call is properly isolated, and its own comment explains why: an inherited GIT_WORK_TREE or GIT_DIR would redirect discovery at a different tree. The fallback beneath that call has neither guard the sibling grew. Where git will not answer, the loop walks upward accepting any directory whose git entry merely exists, with no shape check of the kind plausibleRepository performs and no ownership check of the kind foreignOwnerRefusal performs, so an empty directory named for the marker in a shared ancestor, or a real repository another uid laid there, would bound the ledger root exactly as it bounded the rules root before that fix. @@ -19,3 +23,7 @@ CORRECTED 2026-09-09, major to minor, on reachability. This record first asserte What remains, and why this is still worth a record: the gap in the walk is real, and the tree treats this function as the fixed exemplar of repo-root discovery, cited by name in the resolution of iss-311 as the shape two other resolvers were corrected to match, so the next surface that leaves the root empty inherits an unhardened walk with nothing saying so. That the branch is unreachable is a property of today's callers rather than of the function, and no test pins it. Fix direction: route the fallback through the same shape and ownership gate the rules resolver uses, reusing plausibleRepository and the ownership refusal rather than restating them, and honour the same explicit opt-in so a container bind mount and a shared CI checkout keep working; or delete the walk and refuse outright when git cannot answer. Detector: with git unable to answer, a walk reaching a directory that carries only an empty git marker must resolve no repo root, and a marker root owned by another uid must be refused unless the caller has declared it. The separate defect that the front doors never call this helper at all is recorded as iss-2609090951291524. AMENDED 2026-09-09, in the change that fixed iss-2609090951291524. The verdict above stands and the mechanism holding it has moved, so the sentence that carried it is restated here rather than left to read as current. The capture front doors no longer hand their working directory to the core: every capture verb resolves the checkout root through capture.LedgerRoot, which asks git and refuses BOTH remaining states — a repo-shaped tree git will not answer for, and no repository above at all — instead of falling through to the walk this record is about. The fallback branch is therefore still dead in shipped code, now because the one resolver that could have reached it declines to and says by name that it declines, rather than because no caller resolves anything. Nothing this record asks for is done: the walk still accepts any directory carrying the marker name, still has neither the shape check nor the ownership gate its rules-root sibling grew, is still the exemplar cited in the resolution of iss-311, and still has no test pinning either gap. + +## Grounds + +- pursued: no ledger root is ever resolved from a directory git will not answer for; a resolveRoots call succeeding beneath an empty planted marker would show it wrong From 1a3f3860df443303cf9fc9d8e8473fd2e62b4c8c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:10:13 +0100 Subject: [PATCH 12/36] chore: capture the promote remedy's nil-grounds panic Confirmed while fixing the remedy's delimiter: a promotion given no grounds whose stamp fails after the mint dereferences the absent grounds. Refs: iss-2609251208394294 Assisted-by: Claude:claude-opus-5-5 --- ...panics-with-a-nil-pointer-dereference-when-a.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609251208394294-capture-promote-panics-with-a-nil-pointer-dereference-when-a.md diff --git a/.abcd/work/issues/open/iss-2609251208394294-capture-promote-panics-with-a-nil-pointer-dereference-when-a.md b/.abcd/work/issues/open/iss-2609251208394294-capture-promote-panics-with-a-nil-pointer-dereference-when-a.md new file mode 100644 index 000000000..4467dd888 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251208394294-capture-promote-panics-with-a-nil-pointer-dereference-when-a.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251208394294" +slug: "capture-promote-panics-with-a-nil-pointer-dereference-when-a" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/capture/promote.go" +--- + +capture promote panics with a nil pointer dereference when a promotion given no --grounds fails its ledger stamp after minting the draft: the orphan-draft remedy in Promote calls g.String() on the optional grounds, which is nil once grounds became optional (iss-2609091009111294), so the failure path that exists to name the orphan and its repair crashes instead and the orphan draft is left unreported. Confirmed with a stamp-failure test while fixing the remedy's delimiter. From 14ab2e0700bc70feafb8ece1a59bbbc5c515ff1a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:10:14 +0100 Subject: [PATCH 13/36] fix(capture): promote's orphan remedy fits why the stamp failed The post-mint stamp-failure report attached the link remedy unconditionally and composed it with three defects: - a promotion that lost the race to a concurrent promotion of the same issue was told to link its draft, which is refused as already promoted; the already-promoted refusal now wraps ErrAlreadyPromoted, and that failure names the winner and says to delete the duplicate draft; - the remedy was delimited with single backticks while carrying free-prose grounds, so a backtick in the grounds closed it early; it is now a CommonMark code span one backtick longer than any run inside it; - a promotion given no grounds (optional on the issue route) dereferenced the nil grounds composing the remedy and panicked; the remedy now names --grounds only when grounds were given. One helper, orphanDraftError, serves both the issue and the reading route. Refs: iss-258, iss-2609020154474224, iss-2609251208394294 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 4 +- commands/capture.md | 8 +- internal/core/capture/capture.go | 4 + internal/core/capture/promote.go | 84 +++++++++-- internal/core/capture/promote_remedy_test.go | 131 ++++++++++++++++++ internal/core/capture/promote_test.go | 23 +-- 6 files changed, 219 insertions(+), 35 deletions(-) create mode 100644 internal/core/capture/promote_remedy_test.go diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index 502a56f01..7c34cc990 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -313,7 +313,9 @@ for ad-hoc scribbles. in its `related_issues`, appends the intent to the issue's `related_intents`, and leaves the issue in its folder; an issue already promoted is refused with the existing intent id, and a post-mint stamp failure - names the orphan draft and the repair flag. + names the orphan draft and the repair flag — or, when a concurrent promotion + of the same issue won the race, names the winner and says to delete the + duplicate draft (iss-258). - **Given** a reading item with no disposition, **when** the user records one, **then** a disposition record is written under `.abcd/work/issues/dispositions/`; a second answer to the same item is refused diff --git a/commands/capture.md b/commands/capture.md index 223b05357..10a68535b 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -452,8 +452,12 @@ signal is the keyed disposition, and it has no folder to name. minting, writing both halves — the record into the draft's `related_issues`, the draft into the record's `related_intents` — the repair path when a stamp failed after the mint (the error names the orphan draft and this exact remedy, the promotion's own `--grounds` -included, so it runs as printed), and the path for "I already -filed the intent by hand; link them". Report the `issue_id`, the minted (or +included when it was given any, so it runs as printed; the remedy is a code span +whose fence the grounds cannot close, so copy everything between the fences), and +the path for "I already filed the intent by hand; link them". When the stamp +failed because a concurrent promotion of the same record got there first, the +error names the intent that won and says to delete the duplicate draft instead: +linking it would be refused as already promoted. Report the `issue_id`, the minted (or linked) `intent_id`, and both paths from the JSON. Link mode never touches the draft's `origin`, which was stamped at mint — a diff --git a/internal/core/capture/capture.go b/internal/core/capture/capture.go index 9cadb691a..fde430f54 100644 --- a/internal/core/capture/capture.go +++ b/internal/core/capture/capture.go @@ -330,6 +330,10 @@ var ( ErrAllocatorContention = errors.New("allocator contention") // ErrChecksumMismatch means a concurrent edit occurred during a transition. ErrChecksumMismatch = errors.New("checksum mismatch") + // ErrAlreadyPromoted means the issue already names, and is named by, an + // intent other than the one this call is joining — refused rather than + // promoted twice. + ErrAlreadyPromoted = errors.New("already promoted") // ErrGroundsRefused means the triage's grounds argument was absent, outside // the closed vocabulary, malformed, or below the substance floor. It is one // sentinel for every one of those because they are one thing to a caller — diff --git a/internal/core/capture/promote.go b/internal/core/capture/promote.go index a4cdd2ed2..f64355ec5 100644 --- a/internal/core/capture/promote.go +++ b/internal/core/capture/promote.go @@ -1,10 +1,12 @@ package capture import ( + "errors" "fmt" "path/filepath" "strings" + "github.com/intentdriven/abcd/internal/core/grounds" "github.com/intentdriven/abcd/internal/core/intent" "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/provenance" @@ -158,7 +160,7 @@ func Promote(req PromoteRequest) (PromoteResult, error) { if into, err := promotedInto(repoRoot, req.ID, asStrList(fm["related_intents"]), ""); err != nil { return PromoteResult{}, err } else if into != "" { - return PromoteResult{}, fmt.Errorf("%s is already promoted to %s; refusing to promote twice", req.ID, into) + return PromoteResult{}, fmt.Errorf("%s is %w to %s; refusing to promote twice", req.ID, ErrAlreadyPromoted, into) } // Establish that the RECORD can accept the append, before anything is minted. // requireGrounds above already gated the grounds TEXT; what it cannot answer @@ -240,6 +242,9 @@ func Promote(req PromoteRequest) (PromoteResult, error) { path string status State } + if beforePromoteStampHook != nil { + beforePromoteStampHook() + } stampErr := withLedgerLock(repoRoot, issuesRoot, func() error { src, status, err := findIssue(issuesRoot, req.ID) if err != nil { @@ -261,7 +266,7 @@ func Promote(req PromoteRequest) (PromoteResult, error) { if into, err := promotedInto(repoRoot, req.ID, related, itdID); err != nil { return err } else if into != "" { - return fmt.Errorf("%s is already promoted to %s; refusing to promote twice", req.ID, into) + return fmt.Errorf("%s is %w to %s; refusing to promote twice", req.ID, ErrAlreadyPromoted, into) } newContent, err := setListField(content, "related_intents", appendUnique(related, itdID)) if err != nil { @@ -299,14 +304,7 @@ func Promote(req PromoteRequest) (PromoteResult, error) { }) if stampErr != nil { if !linked { - // The mint already happened; report the orphan and the repair verb. The - // remedy carries the grounds this call was given: the issue route refuses - // without them, so a remedy that named only --intent refused on its own - // text for every orphan (iss-2609012037130181), and the repair stamps - // the same conjecture the failed promotion was pursuing. - return PromoteResult{}, fmt.Errorf( - "%w — the minted draft %s (%s) is orphaned; complete the link with `abcd capture promote %s --intent %s --grounds %s`", - stampErr, itdID, intentPath, req.ID, itdID, shellQuoted(g.String())) + return PromoteResult{}, orphanDraftError(stampErr, req.ID, itdID, intentPath, g) } return PromoteResult{}, stampErr } @@ -380,6 +378,64 @@ func appendUnique(list []string, id string) []string { return append(out, id) } +// orphanDraftError reports a promotion whose draft was minted and whose stamp +// then failed, with the remedy that applies to WHY it failed. +// +// A stamp refused as already promoted means a concurrent promotion of the same +// record won the race between the mint and the lock (iss-258). The link remedy +// would itself be refused as already promoted, so the report says what is true +// instead: the draft this call minted duplicates the winner's, and it is deleted +// by hand. Every other failure leaves the draft orphaned, and the remedy is the +// link that completes it. +// +// The link remedy carries the grounds this call was given, when it was given +// any, so the repair stamps the same conjecture (iss-2609012037130181); a +// promotion given none gets a remedy naming none (grounds are optional on the +// issue route). It is delimited as a code span the command cannot close +// (codeSpan), because the grounds are free prose and may carry a backtick +// (iss-2609020154474224). +func orphanDraftError(stampErr error, recordID, itdID, intentPath string, g *grounds.Grounds) error { + if errors.Is(stampErr, ErrAlreadyPromoted) { + return fmt.Errorf( + "%w — this promotion lost that race, so the draft it minted, %s (%s), duplicates it: delete the duplicate draft %s; linking it would be refused as already promoted", + stampErr, itdID, intentPath, intentPath) + } + cmd := "abcd capture promote " + recordID + " --intent " + itdID + if g != nil { + cmd += " --grounds " + shellQuoted(g.String()) + } + return fmt.Errorf("%w — the minted draft %s (%s) is orphaned; complete the link with %s", + stampErr, itdID, intentPath, codeSpan(cmd)) +} + +// codeSpan delimits s as a CommonMark code span that s cannot close: the fence +// is one backtick longer than the longest backtick run inside s, and a space +// pads each end when s begins or ends with a backtick. What a renderer, a test +// or a person reads between the fences is therefore always the whole of s. +func codeSpan(s string) string { + longest, run := 0, 0 + for i := 0; i < len(s); i++ { + if s[i] == '`' { + run++ + if run > longest { + longest = run + } + continue + } + run = 0 + } + fence := strings.Repeat("`", longest+1) + if strings.HasPrefix(s, "`") || strings.HasSuffix(s, "`") { + return fence + " " + s + " " + fence + } + return fence + s + fence +} + +// beforePromoteStampHook, when non-nil, fires on the issue route between the +// mint and the ledger-locked stamp — the window a concurrent promotion of the +// same issue lands in. A test-only seam (nil in production) for iss-258. +var beforePromoteStampHook func() + // shellQuoted wraps s in SINGLE quotes for the shell a remedy is pasted into, // spelling an embedded quote the only way single quoting can ('\”: close, // escaped quote, reopen). It exists so the orphan remedy runs as printed: a @@ -450,7 +506,7 @@ func promoteReadingItem(repoRoot, issuesRoot string, req PromoteRequest) (Promot // A reading item carries no loose relation — only promote writes its // related_intents — so any entry at all is the forward half of a promotion. if existing := asStrList(fm["related_intents"]); len(existing) > 0 { - return PromoteResult{}, fmt.Errorf("%s is already promoted to %s; refusing to promote twice", req.ID, existing[0]) + return PromoteResult{}, fmt.Errorf("%s is %w to %s; refusing to promote twice", req.ID, ErrAlreadyPromoted, existing[0]) } // The run half of the origin pair, taken from WHERE THE ITEM WAS FOUND: the // item's bucket IS its run directory, which is the same join the provenance @@ -551,7 +607,7 @@ func promoteReadingItem(repoRoot, issuesRoot string, req PromoteRequest) (Promot return err } if existing := asStrList(fm["related_intents"]); len(existing) > 0 { - return fmt.Errorf("%s is already promoted to %s; refusing to promote twice", req.ID, existing[0]) + return fmt.Errorf("%s is %w to %s; refusing to promote twice", req.ID, ErrAlreadyPromoted, existing[0]) } // Re-read the standing answer HERE, not only in the pre-flight. A // disposition landing between the two — an acceptance superseded by a @@ -582,9 +638,7 @@ func promoteReadingItem(repoRoot, issuesRoot string, req PromoteRequest) (Promot }) if stampErr != nil { if !linked { - return PromoteResult{}, fmt.Errorf( - "%w — the minted draft %s (%s) is orphaned; complete the link with `abcd capture promote %s --intent %s`", - stampErr, itdID, intentPath, req.ID, itdID) + return PromoteResult{}, orphanDraftError(stampErr, req.ID, itdID, intentPath, nil) } return PromoteResult{}, stampErr } diff --git a/internal/core/capture/promote_remedy_test.go b/internal/core/capture/promote_remedy_test.go new file mode 100644 index 000000000..effb799a0 --- /dev/null +++ b/internal/core/capture/promote_remedy_test.go @@ -0,0 +1,131 @@ +package capture + +import ( + "errors" + "strings" + "testing" +) + +// remedySpan returns the text of the CommonMark code span that follows lead in +// msg: a run of N backticks opens it and the next run of EXACTLY N closes it, +// and one space is stripped from each end when both are present. It is the +// reading a renderer, a test or any tool applies, so a remedy is well delimited +// only when this returns the whole command. +func remedySpan(t *testing.T, msg, lead string) string { + t.Helper() + i := strings.Index(msg, lead) + if i < 0 { + t.Fatalf("message carries no %q: %s", lead, msg) + } + rest := msg[i+len(lead):] + n := 0 + for n < len(rest) && rest[n] == '`' { + n++ + } + if n == 0 { + t.Fatalf("no code span follows %q: %s", lead, msg) + } + body := rest[n:] + for j := 0; j < len(body); { + if body[j] != '`' { + j++ + continue + } + k := j + for k < len(body) && body[k] == '`' { + k++ + } + if k-j == n { + span := body[:j] + if len(span) >= 2 && span[0] == ' ' && span[len(span)-1] == ' ' { + span = span[1 : len(span)-1] + } + return span + } + j = k + } + t.Fatalf("the code span after %q is never closed: %s", lead, msg) + return "" +} + +// TestPromoteOrphanRemedySpanIsTheWholeCommand is iss-2609020154474224: the +// remedy carries the promotion's grounds, which are free prose, and a backtick +// in them closed a single-backtick delimiter early, so whatever read the text +// between the delimiters got a truncated command. The delimiter is now one the +// argument cannot close. +func TestPromoteOrphanRemedySpanIsTheWholeCommand(t *testing.T) { + repo, ir, issID := promoteFixture(t, "the stamp will fail after the mint") + stampWriteHook = func(string, []byte) error { return errors.New("simulated unwritable ledger") } + t.Cleanup(func() { stampWriteHook = nil }) + const g = "pursued: we expect the `--flag` spelling and a ``double`` run to survive in the remedy" + _, err := Promote(PromoteRequest{Grounds: g, RepoRoot: repo, IssuesRoot: ir, ID: issID}) + if err == nil { + t.Fatal("stamp into an unwritable ledger must fail") + } + span := remedySpan(t, err.Error(), "complete the link with ") + words := shellWords(t, span) + if len(words) != 8 || words[6] != "--grounds" || words[7] != g { + t.Fatalf("the delimited remedy is not the whole command: %q (from %s)", words, err) + } +} + +// TestPromoteOrphanRemedyWithoutGroundsNamesNoGrounds: grounds are optional on +// the issue route, so a promotion given none can fail its stamp too. The remedy +// then carries no --grounds, and the report is an error, never a crash on the +// absent value. +func TestPromoteOrphanRemedyWithoutGroundsNamesNoGrounds(t *testing.T) { + repo, ir, issID := promoteFixture(t, "the stamp will fail after the mint") + stampWriteHook = func(string, []byte) error { return errors.New("simulated unwritable ledger") } + t.Cleanup(func() { stampWriteHook = nil }) + var err error + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("a failed promotion given no grounds panicked composing its remedy: %v", r) + } + }() + _, err = Promote(PromoteRequest{RepoRoot: repo, IssuesRoot: ir, ID: issID}) + }() + if err == nil { + t.Fatal("stamp into an unwritable ledger must fail") + } + span := remedySpan(t, err.Error(), "complete the link with ") + orphan := soleDraftID(t, repo) + if span != "abcd capture promote "+issID+" --intent "+orphan { + t.Fatalf("remedy = %q, want the link command with no --grounds", span) + } +} + +// TestPromoteLostRaceRemedyNamesTheDuplicateDraft is iss-258: a promotion that +// loses the race to a concurrent promotion of the same issue has minted a +// duplicate draft, and the link remedy would itself be refused as +// already-promoted. The report names the promotion that won and says to delete +// the duplicate instead. +func TestPromoteLostRaceRemedyNamesTheDuplicateDraft(t *testing.T) { + repo, ir, issID := promoteFixture(t, "two promotions race") + var winner string + beforePromoteStampHook = func() { + beforePromoteStampHook = nil + res, err := Promote(PromoteRequest{Grounds: testGrounds, RepoRoot: repo, IssuesRoot: ir, ID: issID}) + if err != nil { + t.Fatalf("the winning promotion: %v", err) + } + winner = res.IntentID + } + t.Cleanup(func() { beforePromoteStampHook = nil }) + + _, err := Promote(PromoteRequest{Grounds: testGrounds, RepoRoot: repo, IssuesRoot: ir, ID: issID}) + if err == nil { + t.Fatal("the losing promotion must fail") + } + if !errors.Is(err, ErrAlreadyPromoted) { + t.Fatalf("want ErrAlreadyPromoted, got %v", err) + } + msg := err.Error() + if strings.Contains(msg, "complete the link") { + t.Fatalf("the lost race still advises the link, which would be refused: %s", msg) + } + if !strings.Contains(msg, winner) || !strings.Contains(msg, "delete the duplicate draft") { + t.Fatalf("the report must name the winning intent %s and say to delete the duplicate: %s", winner, msg) + } +} diff --git a/internal/core/capture/promote_test.go b/internal/core/capture/promote_test.go index 26f09344d..7fe025771 100644 --- a/internal/core/capture/promote_test.go +++ b/internal/core/capture/promote_test.go @@ -758,22 +758,11 @@ func promoteOrphanRemedyRunsAsPrinted(t *testing.T, grounds string) { t.Fatal("stamp into an unwritable ledger must fail") } - const lead = "complete the link with `" - msg := err.Error() - start := strings.Index(msg, lead) - if start < 0 { - t.Fatalf("orphan report carries no remedy: %v", err) - } - rest := msg[start+len(lead):] - // The LAST backtick, not the first: the message delimits the remedy with - // backticks, and grounds may legitimately contain one — which the first-hit - // search truncated the remedy at, mid-argument (captured separately as the - // message's own ambiguity; the remedy runs correctly when copied whole). - end := strings.LastIndex(rest, "`") - if end < 0 { - t.Fatalf("remedy is not closed: %v", err) - } - words := shellWords(t, rest[:end]) + // The remedy is read as the code span it is delimited as (remedySpan), by + // the CommonMark rule a renderer applies: grounds may carry a backtick, and + // the fence is chosen so they cannot close it (iss-2609020154474224). + span := remedySpan(t, err.Error(), "complete the link with ") + words := shellWords(t, span) if len(words) < 4 || words[0] != "abcd" || words[1] != "capture" || words[2] != "promote" || words[3] != issID { t.Fatalf("remedy is not `abcd capture promote %s ...`: %q", issID, words) } @@ -798,7 +787,7 @@ func promoteOrphanRemedyRunsAsPrinted(t *testing.T, grounds string) { } res, err := Promote(req) if err != nil { - t.Fatalf("the remedy as printed refused: %v\nremedy: %s", err, rest[:end]) + t.Fatalf("the remedy as printed refused: %v\nremedy: %s", err, span) } if !res.Linked || res.IntentID != req.LinkIntent { t.Fatalf("the remedy must link the orphan draft, got %+v", res) From d73321661931242dd112b980a7320cf4e85c2d41 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:10:25 +0100 Subject: [PATCH 14/36] =?UTF-8?q?chore:=20resolve=20iss-258=20=E2=80=94=20?= =?UTF-8?q?lost=20promote=20race=20names=20the=20duplicate=20draft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-258 Assisted-by: Claude:claude-opus-5-5 --- .../iss-258-promote-race-remedy-misdirects.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) rename .abcd/work/issues/{open => resolved}/iss-258-promote-race-remedy-misdirects.md (63%) diff --git a/.abcd/work/issues/open/iss-258-promote-race-remedy-misdirects.md b/.abcd/work/issues/resolved/iss-258-promote-race-remedy-misdirects.md similarity index 63% rename from .abcd/work/issues/open/iss-258-promote-race-remedy-misdirects.md rename to .abcd/work/issues/resolved/iss-258-promote-race-remedy-misdirects.md index b6d28940b..06c69106d 100644 --- a/.abcd/work/issues/open/iss-258-promote-race-remedy-misdirects.md +++ b/.abcd/work/issues/resolved/iss-258-promote-race-remedy-misdirects.md @@ -7,6 +7,14 @@ category: "ux" source: "impl-review" found_during: "spc-24 build, ruthless-reviewer note" found_at: "internal/core/capture/promote.go" +resolution: "The already-promoted refusal wraps ErrAlreadyPromoted, and a promotion whose stamp is refused that way (it lost the race to a concurrent promotion) is told which intent won and to delete its duplicate draft, instead of the link remedy that would be refused." +impact: fix +resolved_by: + commit: "14ab2e07" --- -capture promote's post-mint stamp-failure remedy is attached unconditionally, so a promote that loses a race against a concurrent promote of the same issue emits advice that will refuse: B mints itd-Y, fails the under-lock re-check because A stamped itd-X, and B's error still says 'complete the link with capture promote iss-N --intent itd-Y' — running that hits the already-promoted refusal, and the duplicate draft itd-Y must be deleted by hand. The wrapped error does carry the true itd-X fact. Accepted residue class in spc-24 (no cross-store lock); a smarter remedy would special-case the already-promoted stamp error and say 'delete the duplicate draft' instead. \ No newline at end of file +capture promote's post-mint stamp-failure remedy is attached unconditionally, so a promote that loses a race against a concurrent promote of the same issue emits advice that will refuse: B mints itd-Y, fails the under-lock re-check because A stamped itd-X, and B's error still says 'complete the link with capture promote iss-N --intent itd-Y' — running that hits the already-promoted refusal, and the duplicate draft itd-Y must be deleted by hand. The wrapped error does carry the true itd-X fact. Accepted residue class in spc-24 (no cross-store lock); a smarter remedy would special-case the already-promoted stamp error and say 'delete the duplicate draft' instead. + +## Grounds + +- pursued: the losing side of a promote race gets advice that works; a lost-race report still naming the --intent link would show it wrong From 72f28fefa2605dea4502a0b6f290ab3862c6ea76 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:10:26 +0100 Subject: [PATCH 15/36] =?UTF-8?q?chore:=20resolve=20iss-2609020154474224?= =?UTF-8?q?=20=E2=80=94=20remedy=20delimiter=20cannot=20be=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609020154474224 Assisted-by: Claude:claude-opus-5-5 --- ...n-draft-report-in-promote-internal-core-capture-pro.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609020154474224-the-orphan-draft-report-in-promote-internal-core-capture-pro.md (66%) diff --git a/.abcd/work/issues/open/iss-2609020154474224-the-orphan-draft-report-in-promote-internal-core-capture-pro.md b/.abcd/work/issues/resolved/iss-2609020154474224-the-orphan-draft-report-in-promote-internal-core-capture-pro.md similarity index 66% rename from .abcd/work/issues/open/iss-2609020154474224-the-orphan-draft-report-in-promote-internal-core-capture-pro.md rename to .abcd/work/issues/resolved/iss-2609020154474224-the-orphan-draft-report-in-promote-internal-core-capture-pro.md index 4e0625c27..e2f94a035 100644 --- a/.abcd/work/issues/open/iss-2609020154474224-the-orphan-draft-report-in-promote-internal-core-capture-pro.md +++ b/.abcd/work/issues/resolved/iss-2609020154474224-the-orphan-draft-report-in-promote-internal-core-capture-pro.md @@ -9,6 +9,14 @@ found_during: "autonomous-run-2026-09-01" origin: researcher-authored production_mode: hand-written found_at: "internal/core/capture/promote.go" +resolution: "The orphan-draft remedy is delimited as a CommonMark code span whose fence is one backtick longer than any run inside the command (codeSpan), so grounds carrying backticks cannot close it and the delimited text is always the whole command." +impact: fix +resolved_by: + commit: "14ab2e07" --- The orphan-draft report in Promote (internal/core/capture/promote.go) delimits its remedy with backticks — complete the link with (backtick)abcd capture promote ...(backtick) — and the remedy now carries the promotion's own grounds, which are free prose. Grounds containing a backtick therefore close the delimiter early, so a reader (or a test, or any tool) copying the text between the backticks gets a truncated command; the remedy itself is well formed and runs when copied whole. The fix must establish a delimiter the argument cannot close, so what sits between the delimiters is always the whole remedy. + +## Grounds + +- pursued: whatever reads the text between the remedy's delimiters gets the whole command; grounds with a backtick run yielding a truncated span would show it wrong From 69ce9906d4c3f325bed046dc327f34c5dc5e3823 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:10:27 +0100 Subject: [PATCH 16/36] =?UTF-8?q?chore:=20resolve=20iss-2609251208394294?= =?UTF-8?q?=20=E2=80=94=20no=20panic=20on=20a=20grounds-less=20orphan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251208394294 Assisted-by: Claude:claude-opus-5-5 --- ...romote-panics-with-a-nil-pointer-dereference-when-a.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251208394294-capture-promote-panics-with-a-nil-pointer-dereference-when-a.md (66%) diff --git a/.abcd/work/issues/open/iss-2609251208394294-capture-promote-panics-with-a-nil-pointer-dereference-when-a.md b/.abcd/work/issues/resolved/iss-2609251208394294-capture-promote-panics-with-a-nil-pointer-dereference-when-a.md similarity index 66% rename from .abcd/work/issues/open/iss-2609251208394294-capture-promote-panics-with-a-nil-pointer-dereference-when-a.md rename to .abcd/work/issues/resolved/iss-2609251208394294-capture-promote-panics-with-a-nil-pointer-dereference-when-a.md index 4467dd888..b4e499ed5 100644 --- a/.abcd/work/issues/open/iss-2609251208394294-capture-promote-panics-with-a-nil-pointer-dereference-when-a.md +++ b/.abcd/work/issues/resolved/iss-2609251208394294-capture-promote-panics-with-a-nil-pointer-dereference-when-a.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/capture/promote.go" +resolution: "The orphan-draft remedy names --grounds only when the promotion was given grounds, so a grounds-less promotion whose stamp fails reports its orphan draft and the link remedy instead of panicking on the nil grounds." +impact: fix +resolved_by: + commit: "14ab2e07" --- capture promote panics with a nil pointer dereference when a promotion given no --grounds fails its ledger stamp after minting the draft: the orphan-draft remedy in Promote calls g.String() on the optional grounds, which is nil once grounds became optional (iss-2609091009111294), so the failure path that exists to name the orphan and its repair crashes instead and the orphan draft is left unreported. Confirmed with a stamp-failure test while fixing the remedy's delimiter. + +## Grounds + +- pursued: a failed promotion never crashes composing its remedy; a stamp failure without grounds that panics would show it wrong From 243414a3d753f6fa799613b1425e9ffbc2a897c5 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:11:57 +0100 Subject: [PATCH 17/36] fix(capture): judge a quoted impact on its raw scalar, as the gate does record-lint's issue_impact_valid and the release derivation read impact as the raw scalar, so a quoted legal value ("fix") and a quoted empty one ("") are blocked there; the capture parser unquoted them first and the reader accepted both. The parser now keeps a quoted impact's raw token (rawScalarKeys), so validateStrict reaches the gate's verdict. A differential test runs one table of spellings through both readings. Refs: iss-2608261133218490 Assisted-by: Claude:claude-opus-5-5 --- .../capture/impact_quoted_internal_test.go | 64 +++++++++++++++++++ internal/core/capture/parse.go | 16 ++++- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 internal/core/capture/impact_quoted_internal_test.go diff --git a/internal/core/capture/impact_quoted_internal_test.go b/internal/core/capture/impact_quoted_internal_test.go new file mode 100644 index 000000000..636527868 --- /dev/null +++ b/internal/core/capture/impact_quoted_internal_test.go @@ -0,0 +1,64 @@ +package capture + +import ( + "testing" + + "github.com/intentdriven/abcd/internal/core/changelog" + "github.com/intentdriven/abcd/internal/core/frontmatter" +) + +// gateImpactVerdict is record-lint's issue_impact_valid reading of one impact +// line, restated from checkIssueImpact over the same shared primitives +// (frontmatter.Fields for the RAW scalar, frontmatter.IsNull, and +// changelog.ParseImpact): a null passes on an open record, anything else must +// parse as an impact exactly as written, quotes included. +func gateImpactVerdict(line string) bool { + f := frontmatter.Fields([]string{"---", line, "---"})["impact"] + if frontmatter.IsNull(f.Value) { + return true + } + _, err := changelog.ParseImpact(f.Value) + return err == nil +} + +// TestImpactQuotingReachesOneVerdict is iss-2608261133218490's differential +// test: the capture reader and the committed-ledger gate must reach the same +// verdict on every spelling of one impact value. The gate reads the raw scalar, +// so a quoted legal enum ("fix") and a quoted empty value ("") are refused +// there — and were accepted here, because the parser unquotes before the +// validator sees the value. A record the gate blocks must be one the reader +// refuses, or it loads and resolves cleanly past its own blocker. +func TestImpactQuotingReachesOneVerdict(t *testing.T) { + valid := func() map[string]any { + return map[string]any{ + "schema_version": 1, "id": "iss-1", "slug": "x", "severity": "minor", + "category": "bug", "source": "agent-finding", "found_during": "review", + } + } + for _, line := range []string{ + "impact: fix", + "impact: internal", + "impact:", + "impact: null", + `impact: "fix"`, + `impact: "internal"`, + `impact: ""`, + `impact: "null"`, + "impact: sideways", + `impact: "sideways"`, + } { + t.Run(line, func(t *testing.T) { + fm, err := parseFrontmatterBlock([]string{line}) + if err != nil { + t.Fatalf("parse %q: %v", line, err) + } + m := valid() + m["impact"] = fm["impact"] + reader := validateStrict(m) == nil + gate := gateImpactVerdict(line) + if reader != gate { + t.Errorf("%q: the reader accepts=%v but the gate accepts=%v — one value, two verdicts", line, reader, gate) + } + }) + } +} diff --git a/internal/core/capture/parse.go b/internal/core/capture/parse.go index e2c99bbf8..f10a65daa 100644 --- a/internal/core/capture/parse.go +++ b/internal/core/capture/parse.go @@ -2,9 +2,10 @@ package capture import ( "fmt" - "github.com/intentdriven/abcd/internal/core/frontmatter" "strconv" "strings" + + "github.com/intentdriven/abcd/internal/core/frontmatter" ) // parseFrontmatterAndBody splits text into a frontmatter map and a body, @@ -192,12 +193,25 @@ func parseFrontmatterBlock(lines []string) (map[string]any, error) { if str, isStr := val.(string); isStr && !quotedScalar(rest) && frontmatter.IsNull(str) { val = "" } + // A key the committed-ledger gate judges on its RAW scalar keeps its raw + // token when quoted, so the validator judges the bytes the gate judges. + // impact is written bare by every verb, and the gate refuses any quoted + // spelling — "fix" and "" included — because the release derivation reads + // the raw scalar too; unquoted here, those two were accepted by the reader + // while record-lint blocked the same record (iss-2608261133218490). + if rawScalarKeys[key] && quotedScalar(rest) { + val = rest + } fm[key] = val i++ } return fm, nil } +// rawScalarKeys are the keys a reader downstream of the ledger judges on the +// raw scalar — quotes included — rather than on its decoded string. +var rawScalarKeys = map[string]bool{"impact": true} + // parseScalarOrList decodes one YAML value into string, int, or []string. func parseScalarOrList(s string) (any, error) { if s == "[]" { From ad6f81bbe8f2d0e1d384116b35b892105fd1dd2b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:11:59 +0100 Subject: [PATCH 18/36] =?UTF-8?q?chore:=20resolve=20iss-2608261133218490?= =?UTF-8?q?=20=E2=80=94=20one=20impact=20verdict=20per=20spelling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2608261133218490 Assisted-by: Claude:claude-opus-5-5 --- ...-accepts-quoted-enum-impact-lint-blocks.md | 12 ----------- ...-accepts-quoted-enum-impact-lint-blocks.md | 20 +++++++++++++++++++ 2 files changed, 20 insertions(+), 12 deletions(-) delete mode 100644 .abcd/work/issues/open/iss-2608261133218490-capture-accepts-quoted-enum-impact-lint-blocks.md create mode 100644 .abcd/work/issues/resolved/iss-2608261133218490-capture-accepts-quoted-enum-impact-lint-blocks.md diff --git a/.abcd/work/issues/open/iss-2608261133218490-capture-accepts-quoted-enum-impact-lint-blocks.md b/.abcd/work/issues/open/iss-2608261133218490-capture-accepts-quoted-enum-impact-lint-blocks.md deleted file mode 100644 index 3ccb0d8f3..000000000 --- a/.abcd/work/issues/open/iss-2608261133218490-capture-accepts-quoted-enum-impact-lint-blocks.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -schema_version: 1 -id: "iss-2608261133218490" -slug: "capture-accepts-quoted-enum-impact-lint-blocks" -severity: "minor" -category: "bug" -source: "agent-finding" -found_during: "bughunt-round-8" -found_at: "internal/core/capture/validate.go:104" ---- - -capture validateStrict accepts a quoted legal enum impact and a quoted empty impact that record-lint and the release derivation block; the quoted-scalar acceptance is wider than the nulls the round-8 fix closed \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608261133218490-capture-accepts-quoted-enum-impact-lint-blocks.md b/.abcd/work/issues/resolved/iss-2608261133218490-capture-accepts-quoted-enum-impact-lint-blocks.md new file mode 100644 index 000000000..dcf76a9b7 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608261133218490-capture-accepts-quoted-enum-impact-lint-blocks.md @@ -0,0 +1,20 @@ +--- +schema_version: 1 +id: "iss-2608261133218490" +slug: "capture-accepts-quoted-enum-impact-lint-blocks" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "bughunt-round-8" +found_at: "internal/core/capture/validate.go:104" +resolution: "The capture parser keeps a quoted impact's raw token, so validateStrict refuses a quoted legal enum and a quoted empty impact exactly as record-lint's issue_impact_valid does; a differential test pins one verdict per spelling across both readings." +impact: fix +resolved_by: + commit: "243414a3" +--- + +capture validateStrict accepts a quoted legal enum impact and a quoted empty impact that record-lint and the release derivation block; the quoted-scalar acceptance is wider than the nulls the round-8 fix closed + +## Grounds + +- pursued: no record the impact gate blocks loads cleanly through the capture reader; any spelling in the differential table where the two verdicts part would show it wrong From df17a115a3b0d47b654e077ab054f9450d9cc0b8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:14:26 +0100 Subject: [PATCH 19/36] feat(capture): say when a record is uncommitted, at the write and on the board A ledger record held only as an untracked file is in no state to anyone but the checkout holding it: no branch cut from the default branch sees it, and no gate reading the committed tree reads it. capture wrote such a record and reported plain success, and the status board listed it as an equal member of its folder. One git status read over the ledger (uncommittedLedgerPaths) now drives CaptureResult.uncommitted, Issue.uncommitted on status and list rows, and StatusResult.uncommitted_count. The write prints an "uncommitted:" line; the board marks the row and counts the records. Where git cannot answer, nothing is marked. Refs: iss-2609100508570527 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 8 ++- commands/capture.md | 9 ++- internal/core/capture/capture.go | 20 +++++- internal/core/capture/uncommitted.go | 63 +++++++++++++++++++ internal/core/capture/uncommitted_test.go | 46 ++++++++++++++ internal/core/capture/workflow.go | 19 ++++++ internal/surface/cli/capture_surface_test.go | 18 ++++++ internal/surface/cli/cli.go | 21 ++++++- 8 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 internal/core/capture/uncommitted.go create mode 100644 internal/core/capture/uncommitted_test.go diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index 7c34cc990..f6bc59177 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -45,10 +45,14 @@ refuses is counted in none of the three totals, so the board counts it beside them and names, for each one, the reader layer that refused it: the filename, the guarded read, the frontmatter parse, the schema or the folder and filename invariants. The layer is what tells a reader whether the record or the reader is -the side to fix (iss-2609120452071388). +the side to fix (iss-2609120452071388). The board also counts the records git +reports as untracked or changed and marks each such row: folder membership is a +status only once the file is committed, so an uncommitted record is in no state to +any other branch, worktree or gate (iss-2609100508570527). **`/abcd:capture ""`** is the fast path: it appends a structured entry -with an auto-assigned `iss-N` and writes it to `open/`. Provenance and taxonomy +with an auto-assigned `iss-N` and writes it to `open/`, and says that the record +is not committed yet whenever git reports it so, which for a new record is always. Provenance and taxonomy are caller-supplied flags. Severity, category, source and the found-during context each carry a default, so the fast path stays fast; the location, slug and dependency flags have none. The `origin` field is derived from the verb that diff --git a/commands/capture.md b/commands/capture.md index 10a68535b..fc82cb21a 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -33,6 +33,11 @@ Summarise the JSON for the user: `open_count` / `resolved_count` / `wontfix_count`, and for each entry in `recent_open` its `id`, `severity`, and `slug`. No `iss-*.md` file is created, moved, or mutated by this invocation. +When `uncommitted_count` is non-zero, say how many records are not committed +and name the `recent_open` entries carrying `uncommitted: true`: an untracked or +changed record file is in no state to any other branch, worktree or gate until it +is committed. + When `skipped_count` is non-zero, say so: those are files that claim to be records and that none of the three totals counts, because the reader refused them. Each entry in `skipped` carries its `path`, the `layer` that refused it @@ -72,7 +77,9 @@ the ledger, and an edge to a record captured later is written afterwards with (`hand-written|dictated-and-formatted|scribe-transcribed`, default: the repo's declared mode, else `hand-written`). Report the new `id`, `status`, and `path` from the JSON. Report `redacted` too whenever it is non-zero: it counts the spans rewritten before the text was -written, and the user needs to know their wording was changed. +written, and the user needs to know their wording was changed. When +`uncommitted` is true, say that the record is not in git yet: until it is +committed no other branch, worktree or gate can see it. `--category lapse` takes `--lapsed-at`, which has no default: a lapse capture that omits it records no instant, never the write-up time. The refusal on an diff --git a/internal/core/capture/capture.go b/internal/core/capture/capture.go index fde430f54..2cd379ad5 100644 --- a/internal/core/capture/capture.go +++ b/internal/core/capture/capture.go @@ -128,6 +128,12 @@ type Issue struct { // open/ (the priority projection populated by List/Status). Not a stored // field: an empty slice means the issue is unblocked. BlockedByOpen []string `json:"blocked_by_open,omitempty"` + // Uncommitted is true when git reports the record's file untracked or + // changed in this checkout (iss-2609100508570527): folder membership is a + // status signal only once the file is committed, so an uncommitted record is + // in no state to any other branch, worktree or gate. Derived at read time by + // Status and List, never stored; false when git cannot answer. + Uncommitted bool `json:"uncommitted,omitempty"` } // CaptureRequest is the input to Capture (append a new issue). @@ -171,6 +177,11 @@ type CaptureResult struct { // a finding's content without telling whoever filed it (loud-staging). Redacted int `json:"redacted,omitempty"` Degraded string `json:"redaction_degraded,omitempty"` + // Uncommitted is true when git reports the record just written as not yet + // committed — always, in a checkout git answers for, since the file is new. + // It exists so the write can SAY the record reaches no other branch and no + // gate until it is committed (iss-2609100508570527). + Uncommitted bool `json:"uncommitted,omitempty"` } // ResolveRequest moves an open issue to resolved/. @@ -310,9 +321,12 @@ type StatusResult struct { // none of the three totals counts, because the reader refused them. It is // len(Skipped), carried as a count beside the others so the board states // what it excluded next to what it counted (iss-2609120452071388). - SkippedCount int `json:"skipped_count"` - RecentOpen []Issue `json:"recent_open"` // up to 10, newest first - Skipped []SkipRecord `json:"skipped"` + SkippedCount int `json:"skipped_count"` + // UncommittedCount is the number of readable records across the three + // folders that git reports untracked or changed (iss-2609100508570527). + UncommittedCount int `json:"uncommitted_count"` + RecentOpen []Issue `json:"recent_open"` // up to 10, newest first + Skipped []SkipRecord `json:"skipped"` } // Sentinel errors the surface maps to exit codes and messages. Core never diff --git a/internal/core/capture/uncommitted.go b/internal/core/capture/uncommitted.go new file mode 100644 index 000000000..cab624552 --- /dev/null +++ b/internal/core/capture/uncommitted.go @@ -0,0 +1,63 @@ +package capture + +import ( + "path/filepath" + "strings" + + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/gitutil" +) + +// maxStatusBytes bounds the git status read below; a ledger whose uncommitted +// listing exceeds it is reported as unknown rather than read in part. +const maxStatusBytes = 8 << 20 + +// uncommittedLedgerPaths returns the repo-relative slash paths under the ledger +// that git reports as untracked or changed in this checkout, and ok=false when +// git cannot answer (no repository, git absent, a ledger outside the checkout), +// in which case nothing is marked: an unknown state is not reported as either +// (iss-2609100508570527). +// +// It reads `git status --porcelain=v1 -z -uall`, whose NUL-terminated records +// carry each path verbatim; a rename's source record is skipped, since only the +// destination is a file in the ledger now. +func uncommittedLedgerPaths(repoRoot, issuesRoot string) (map[string]bool, bool) { + if !fsutil.PathWithin(issuesRoot, repoRoot, false) { + return nil, false + } + rel, err := filepath.Rel(repoRoot, issuesRoot) + if err != nil { + return nil, false + } + out, err := gitutil.RunCapped(repoRoot, maxStatusBytes, + "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", filepath.ToSlash(rel)) + if err != nil { + return nil, false + } + set := map[string]bool{} + records := strings.Split(out, "\x00") + for i := 0; i < len(records); i++ { + rec := records[i] + if len(rec) < 4 { + continue + } + set[rec[3:]] = true + if st := rec[:2]; st[0] == 'R' || st[0] == 'C' || st[1] == 'R' || st[1] == 'C' { + i++ + } + } + return set, true +} + +// markUncommitted sets Uncommitted on each issue git reports as not committed. +// Issue paths must already be repo-relative (relativiseLedgerPaths). +func markUncommitted(set map[string]bool, issues []Issue) int { + n := 0 + for i := range issues { + if set[filepath.ToSlash(issues[i].Path)] { + issues[i].Uncommitted = true + n++ + } + } + return n +} diff --git a/internal/core/capture/uncommitted_test.go b/internal/core/capture/uncommitted_test.go new file mode 100644 index 000000000..96bd3febd --- /dev/null +++ b/internal/core/capture/uncommitted_test.go @@ -0,0 +1,46 @@ +package capture + +import ( + "path/filepath" + "testing" + + "github.com/intentdriven/abcd/internal/gittest" +) + +// TestCaptureAndStatusSayARecordIsUncommitted is iss-2609100508570527: a +// record the ledger holds only as an untracked file is in no state to anyone +// but this checkout — no branch cut from the default branch sees it, and no gate +// that reads the committed tree reads it. The write says so, and the status +// board marks such a record rather than listing it as an equal member of its +// folder. Once committed, neither says it. +func TestCaptureAndStatusSayARecordIsUncommitted(t *testing.T) { + r := gittest.NewRepo(t) + r.Commit("root") + repo := r.Root() + ir := filepath.Join(repo, LedgerRelPath) + + res, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "a finding", + Severity: SeverityMinor, Category: "bug", Source: "manual-test", Slug: "untracked", FoundDuring: "t"}) + if err != nil { + t.Fatal(err) + } + if !res.Uncommitted { + t.Fatal("the capture result does not say the record it just wrote is uncommitted") + } + st, err := Status(StatusRequest{RepoRoot: repo, IssuesRoot: ir}) + if err != nil { + t.Fatal(err) + } + if st.UncommittedCount != 1 || len(st.RecentOpen) != 1 || !st.RecentOpen[0].Uncommitted { + t.Fatalf("status must mark the untracked record: count=%d rows=%+v", st.UncommittedCount, st.RecentOpen) + } + + r.Commit("file the record") + st, err = Status(StatusRequest{RepoRoot: repo, IssuesRoot: ir}) + if err != nil { + t.Fatal(err) + } + if st.UncommittedCount != 0 || st.RecentOpen[0].Uncommitted { + t.Fatalf("a committed record is still marked uncommitted: count=%d rows=%+v", st.UncommittedCount, st.RecentOpen) + } +} diff --git a/internal/core/capture/workflow.go b/internal/core/capture/workflow.go index d934fd26b..0c72be8f6 100644 --- a/internal/core/capture/workflow.go +++ b/internal/core/capture/workflow.go @@ -122,6 +122,12 @@ func Capture(req CaptureRequest) (CaptureResult, error) { // Machine output carries a repo-relative locator, never an absolute // developer-identity path (iss-81). result.Path = fsutil.RepoRel(repoRoot, result.Path) + // The write says whether the record is committed: it never is yet, and a + // record held only as an untracked file reaches no other branch and no gate + // (iss-2609100508570527). + if set, ok := uncommittedLedgerPaths(repoRoot, issuesRoot); ok { + result.Uncommitted = set[filepath.ToSlash(result.Path)] + } return result, nil } @@ -622,6 +628,9 @@ func List(req ListRequest) (ListResult, error) { sortIssues(issues) prioritise(issues, openIDSet(ir)) relativiseLedgerPaths(repoRoot, issues, skipped) + if set, ok := uncommittedLedgerPaths(repoRoot, ir); ok { + markUncommitted(set, issues) + } // A --json collection is an empty list, never bare null: a consumer that // iterates the rows (the capture.md contract) errors on null. if issues == nil { @@ -669,6 +678,9 @@ func Status(req StatusRequest) (StatusResult, error) { res.WontfixCount = len(wontfix) res.Skipped = append(append(append([]SkipRecord{}, skOpen...), skRes...), skWf...) res.SkippedCount = len(res.Skipped) + // Every readable record, before the recent-open slice is cut, for the + // uncommitted count below. + every := append(append(append([]Issue{}, open...), resolved...), wontfix...) // The same predicate List uses, over the scan already in hand: skOpen carries // the records open/ holds and the reader refused, and they block too. @@ -685,6 +697,13 @@ func Status(req StatusRequest) (StatusResult, error) { } res.RecentOpen = open relativiseLedgerPaths(repoRoot, res.RecentOpen, res.Skipped) + // Uncommitted records are counted over every readable record and marked on + // the rows the board shows (iss-2609100508570527). + if set, ok := uncommittedLedgerPaths(repoRoot, ir); ok { + relativiseLedgerPaths(repoRoot, every, nil) + res.UncommittedCount = markUncommitted(set, every) + markUncommitted(set, res.RecentOpen) + } return res, nil } diff --git a/internal/surface/cli/capture_surface_test.go b/internal/surface/cli/capture_surface_test.go index 88e869497..c1dbd7754 100644 --- a/internal/surface/cli/capture_surface_test.go +++ b/internal/surface/cli/capture_surface_test.go @@ -513,6 +513,24 @@ func TestCaptureStatusBoardCountsWhatItSkippedAndNamesTheLayer(t *testing.T) { } } +// TestCaptureSaysTheRecordIsUncommitted is the surface half of +// iss-2609100508570527: the write says the record is not in git yet, and the +// status board marks the row and counts the uncommitted records. +func TestCaptureSaysTheRecordIsUncommitted(t *testing.T) { + captureLedgerRepo(t) + out := string(runCLI(t, "capture", "a finding nobody has committed", "--slug", "loose")) + if !strings.Contains(out, "uncommitted: the record is not in git yet") { + t.Fatalf("the write does not say the record is uncommitted:\n%s", out) + } + board := string(runCLI(t, "capture")) + if !strings.Contains(board, " loose [uncommitted]") { + t.Fatalf("the board does not mark the uncommitted row:\n%s", board) + } + if !strings.Contains(board, "1 record(s) not committed") { + t.Fatalf("the board does not count the uncommitted records:\n%s", board) + } +} + // TestCaptureLapsedAtWritesTheGivenInstant pins the flag half of spc-60: the // instant handed to --lapsed-at is the instant committed to the record. The // record id is minted from the wall clock, so a surface that dropped, rounded or diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index c6e42559a..495dc8261 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3264,9 +3264,15 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if len(st.RecentOpen) > 0 { fmt.Fprintf(w, "recent open:\n") for _, iss := range st.RecentOpen { - fmt.Fprintf(w, " %s %s %s%s\n", iss.ID, iss.Severity, iss.Slug, blockedNote(iss)) + fmt.Fprintf(w, " %s %s %s%s%s\n", iss.ID, iss.Severity, iss.Slug, uncommittedNote(iss), blockedNote(iss)) } } + // A record held only as an untracked or changed file is in no + // state to anyone but this checkout (iss-2609100508570527), so + // the board counts them rather than list them as equals. + if st.UncommittedCount > 0 { + fmt.Fprintf(w, " %d record(s) not committed — no other branch, worktree or gate reads them until they are\n", st.UncommittedCount) + } // The skipped roster, exactly as `capture list` renders it // (iss-2608261437041050): a record the reader refuses is counted // by none of the three totals above, so a board that printed the @@ -3392,6 +3398,11 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { } return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { fmt.Fprintf(w, "captured %s (%s) — %s\n", res.ID, res.Status, termsafe.Sanitize(res.Path)) + // Folder membership is a status only once the file is committed + // (iss-2609100508570527): say so at the write, where it is cheap. + if res.Uncommitted { + fmt.Fprintf(w, " uncommitted: the record is not in git yet — commit it, or no other branch, worktree or gate will see it\n") + } // Redaction alters what the caller filed, so it is never silent: the // text on disk differs from the text handed in, and only the caller // can judge whether the redacted record still says what they meant. @@ -4189,6 +4200,14 @@ func skippedLine(sk capture.SkipRecord) string { termsafe.Sanitize(sk.Path), layer, termsafe.Sanitize(sk.Error)) } +// uncommittedNote marks a board row whose record git reports as not committed. +func uncommittedNote(iss capture.Issue) string { + if !iss.Uncommitted { + return "" + } + return " [uncommitted]" +} + // blockedNote renders the derived-priority annotation for a row: when the issue // has blocked_by targets still open, " [blocked-by iss-1,iss-2]"; otherwise "". func blockedNote(iss capture.Issue) string { From e33695645878a79049aff1162d720b8bb246a07d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:14:29 +0100 Subject: [PATCH 20/36] =?UTF-8?q?chore:=20resolve=20iss-2609100508570527?= =?UTF-8?q?=20=E2=80=94=20uncommitted=20records=20named?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609100508570527 Assisted-by: Claude:claude-opus-5-5 --- ...-does-not-say-that-the-record-it-wrote-is-untracked.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609100508570527-capture-does-not-say-that-the-record-it-wrote-is-untracked.md (77%) diff --git a/.abcd/work/issues/open/iss-2609100508570527-capture-does-not-say-that-the-record-it-wrote-is-untracked.md b/.abcd/work/issues/resolved/iss-2609100508570527-capture-does-not-say-that-the-record-it-wrote-is-untracked.md similarity index 77% rename from .abcd/work/issues/open/iss-2609100508570527-capture-does-not-say-that-the-record-it-wrote-is-untracked.md rename to .abcd/work/issues/resolved/iss-2609100508570527-capture-does-not-say-that-the-record-it-wrote-is-untracked.md index 9d9383c05..b8157d625 100644 --- a/.abcd/work/issues/open/iss-2609100508570527-capture-does-not-say-that-the-record-it-wrote-is-untracked.md +++ b/.abcd/work/issues/resolved/iss-2609100508570527-capture-does-not-say-that-the-record-it-wrote-is-untracked.md @@ -9,6 +9,10 @@ found_during: "autonomous-run field experiment in a managed repository, 2026-09- origin: researcher-authored production_mode: hand-written found_at: "internal (capture, status render)" +resolution: "capture reports uncommitted on the record it just wrote (a stdout line and the uncommitted JSON field), and the status board marks each uncommitted row and counts uncommitted records (uncommitted_count), all from one git status read over the ledger." +impact: additive +resolved_by: + commit: "df17a115" --- `abcd capture` writes the record file and never says the record is not in git, so a ledger entry can be invisible to every branch and every gate that reads the committed tree. @@ -18,3 +22,7 @@ Observed during an autonomous run in a managed repository. Six issue records exi The store's status model is folder membership, and folder membership is only a status signal once the file is committed. An uncommitted record is in no state at all: it is not open to anyone but the checkout that holds it. Wanted: have `abcd capture` say, at write time, that the record it just wrote is untracked and needs committing; and have the status render mark an untracked or uncommitted record as such rather than showing it as an equal member of its folder. Both are a `git status` read the tool can already do — this repository's own conventions treat an uncommitted peer diff as significant, and the ledger's own writes are the one place that signal is currently dropped. + +## Grounds + +- pursued: a session that files a record is told it is not yet visible to other branches and gates; a fresh capture in a git checkout reporting no uncommitted state would show it wrong From d09f45e43b77c62e64894eefd6e8931b98748d1e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:22:14 +0100 Subject: [PATCH 21/36] feat(capture): a defer verb writes the release cut's waiver pair The release cut's finding guard reads deferred_after and deferral_reason, and no verb wrote them: a deferral was a hand edit of frontmatter that bypassed every validator, done by script in a managed repository across three cuts. capture defer --after --reason writes the pair and appends a dated "## Deferral " body section; the record stays in open/. It refuses, with nothing written, everything the cut's reader would not honour: a tag that is not the checkout's newest release tag, an empty reason, a record that is not open, and a grade other than major or critical. The grade is judged before the tag, so a refusal about the record names the checkout's own. Re-deferring replaces the pair and appends a new section. Wired from the CLI and commands/capture.md; commands/launch.md's waiver route names the verb; the capture and launch brief chapters, the principle, the generated reference, the surface snapshot and the appendix move with it. Refs: iss-2609181223260994 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/04-launch.md | 3 +- .../brief/04-surfaces/06-capture.md | 32 +++- .../pre-existing-is-not-a-defence.md | 2 +- .abcd/development/release/surface.json | 20 ++ commands/capture.md | 27 ++- commands/launch.md | 8 +- docs/reference/cli/commands.md | 13 ++ internal/core/capture/deferral.go | 171 ++++++++++++++++++ internal/core/capture/deferral_test.go | 100 ++++++++++ internal/surface/cli/capture_root_test.go | 12 ++ internal/surface/cli/capture_surface_test.go | 37 ++++ internal/surface/cli/cli.go | 33 ++++ 12 files changed, 447 insertions(+), 11 deletions(-) create mode 100644 internal/core/capture/deferral.go create mode 100644 internal/core/capture/deferral_test.go diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index 0a34d951b..ebd555c2c 100644 --- a/.abcd/development/brief/04-surfaces/04-launch.md +++ b/.abcd/development/brief/04-surfaces/04-launch.md @@ -277,7 +277,8 @@ than a design target. that is absent, misspelled, or outside the ledger's enum. An unreadable grade has not been judged, and "not judged" must not read as "not serious". - **The waiver** is the frontmatter pair `deferred_after` plus - `deferral_reason`, both schema-accepted keys. `deferred_after` names the cut's + `deferral_reason`, both schema-accepted keys, written by the ledger's deferral + verb ([`06-capture.md`](06-capture.md)). `deferred_after` names the cut's **anchor** tag, not the version being derived, which is what makes a waiver single-use: the anchor moves at the next release and every waiver written against the old one lapses, so a deferred finding is re-asked rather than diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index f6bc59177..df005c2d7 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -24,6 +24,7 @@ binary. | Verb | Bucket | Status | |---|---|---| +| `defer` | — | shipped | | `disposition` | — | shipped | | `link` | — | shipped | | `list` | — | shipped | @@ -168,6 +169,18 @@ it: an intent, a spec, or a commit sha. A fourth, the shipped-in release, is mig use only: it names the release that already carried the work, so the record stays out of the current cut. +**Deferring** writes the release cut's waiver onto an open record +(iss-2609181223260994): `deferred_after` naming the anchor tag, `deferral_reason` +stating why, and a dated `## Deferral` section appended to the body, which is the +part of the record a reader sees. The record stays in `open/`, because a deferral +carries a finding past one cut and neither fixes nor declines it. Everything the +cut's reader would not honour is refused at the write, with nothing written: a +tag that is not the checkout's newest release tag, an empty reason, a record that +is not open, and a record whose grade is neither `major` nor `critical`, which +the guard never blocks on. The grade is judged before the tag. A record deferred +past an earlier anchor is deferred again: the pair is replaced and a new section +appended, so each cycle's deferral stays readable in the record. + **Marking an issue wontfix** records an explicit non-action decision and moves the issue to `wontfix/`. Grounds are optional here and override the recorded text only: the token stays `declined`, because a wontfix **is** that non-action. @@ -232,10 +245,10 @@ resolved_by: # optional structured pointer to what resolved it --- ``` -`deferred_after` and `deferral_reason` are the release cut's waiver pair, and no -capture verb writes them: they are added by hand when a `major` or `critical` -finding is to be carried past a cut open, and the changelog guard reads them. -The waiver is granted for one cycle and lapses when the next release re-anchors. +`deferred_after` and `deferral_reason` are the release cut's waiver pair. The +deferral verb writes them when a `major` or `critical` finding is to be carried +past a cut open, and the changelog guard reads them. The waiver is granted for +one cycle and lapses when the next release re-anchors. [`04-launch.md`](04-launch.md) owns the rule they answer to. `lapsed_at` is transcribed from what the source states, never derived from the @@ -380,7 +393,7 @@ _Generated from the command tree; a drift test fails `go test` when this appendi ### `abcd capture` -Sub-verbs: `abcd capture disposition`, `abcd capture link`, `abcd capture list`, `abcd capture mentions`, `abcd capture migrate`, `abcd capture promote`, `abcd capture resolve`, `abcd capture wontfix`. +Sub-verbs: `abcd capture defer`, `abcd capture disposition`, `abcd capture link`, `abcd capture list`, `abcd capture mentions`, `abcd capture migrate`, `abcd capture promote`, `abcd capture resolve`, `abcd capture wontfix`. | Flag | Type | |---|---| @@ -394,6 +407,15 @@ Sub-verbs: `abcd capture disposition`, `abcd capture link`, `abcd capture list`, | `--slug` | string | | `--source` | string | +### `abcd capture defer` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--after` | string | +| `--reason` | string | + ### `abcd capture disposition` Sub-verbs: none. diff --git a/.abcd/development/principles/pre-existing-is-not-a-defence.md b/.abcd/development/principles/pre-existing-is-not-a-defence.md index 18b29e72e..889f31f43 100644 --- a/.abcd/development/principles/pre-existing-is-not-a-defence.md +++ b/.abcd/development/principles/pre-existing-is-not-a-defence.md @@ -96,7 +96,7 @@ the timestamp inside a record id, so the gate's verdict is not decided by a field the record it is judging can edit. The waiver is the `deferred_after` / `deferral_reason` frontmatter pair on the -record. `deferred_after` names the anchor tag the deferral was granted against, +record, written by `abcd capture defer`. `deferred_after` names the anchor tag the deferral was granted against, which makes it single-use: when the next release re-anchors, the waiver lapses and the finding is re-asked. A waiver that names the wrong anchor, or states no reason, leaves the record blocking and says why, in the same fail-safe direction diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 77ac43b01..f45f220b2 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -303,6 +303,26 @@ } ] }, + { + "path": "abcd capture defer", + "hidden": false, + "flags": [ + { + "name": "after", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "reason", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, { "path": "abcd capture disposition", "hidden": false, diff --git a/commands/capture.md b/commands/capture.md index fc82cb21a..fcf9fbb0f 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -1,7 +1,7 @@ --- name: capture -description: Capture issues to the structured per-repo ledger and query them, by invoking the abcd binary. Bare invocation is a read-only status render; disposition/link/list/promote/resolve/wontfix act on the ledger, and migrate rewrites retired back-links. -argument-hint: "[text] | list --open|--resolved|--wontfix|--all | link [--blocked-by ] [--unblock ] | promote --grounds \": \" [--intent ] | promote [--intent ] | resolve --impact --grounds \": \" [--intent ] [--spec ] [--commit ] | wontfix | disposition --state | migrate [--apply]" +description: Capture issues to the structured per-repo ledger and query them, by invoking the abcd binary. Bare invocation is a read-only status render; defer/disposition/link/list/promote/resolve/wontfix act on the ledger, and migrate rewrites retired back-links. +argument-hint: "[text] | list --open|--resolved|--wontfix|--all | link [--blocked-by ] [--unblock ] | promote --grounds \": \" [--intent ] | promote [--intent ] | resolve --impact --grounds \": \" [--intent ] [--spec ] [--commit ] | wontfix | defer --after --reason | disposition --state | migrate [--apply]" --- # `/abcd:capture` — issue ledger @@ -337,6 +337,29 @@ plain resolve: provenance is optional, never guessed. The written members come back in the JSON as `resolved_by`. `wontfix` takes no provenance — a non-action points at nothing. +## Defer a finding past the current release cut + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" capture defer --after --reason "" --json +``` + +The release cut refuses to ship past a `major` or `critical` record captured +since the last release and still open, and one sanctioned way past it is a +deferral stated out loud. `defer` writes it: `deferred_after` (the anchor tag) +and `deferral_reason` in the record's frontmatter, and a dated +`## Deferral ` section appended to its body. The record stays in `open/`. +Report the `id`, `deferred_after` and `deferral_reason` from the JSON, and tell +the user that the waiver lapses when the next release re-anchors, so it must be +renewed then or the finding fixed. Report `redacted` whenever it is non-zero. + +`--after` must be the checkout's newest `vX.Y.Z` release tag, which is the anchor +the cut measures from; any other tag is refused, because the cut would not honour +it. Both flags are required. Everything the cut would not honour is refused and +nothing is written: an empty reason, a record that is not open, and a record +whose severity is neither `major` nor `critical`, since the guard never blocks on +one. Offer the user the other routes too — fix and resolve it, or `wontfix` it — +rather than defaulting to a deferral. + ## Answer a reading item A reading record is what an instrument returned; the researcher's answer to one diff --git a/commands/launch.md b/commands/launch.md index 5ff110c70..6142fd6c6 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -286,8 +286,12 @@ ignored. The whole verdict is on the cut's `findings` JSON key. gate with no special case, because the gate looks only at `open/`, and a wontfix carries a stated reason. That is the conscious, cited non-action the rule asks for, not a loophole in it. -3. **The waiver pair.** Add `deferred_after: ` and a - `deferral_reason:` to the record's frontmatter. Both are schema-accepted keys. +3. **The waiver pair.** Write it with + `"${CLAUDE_PLUGIN_ROOT}/abcd" capture defer --after --reason ""`, + which sets `deferred_after` and `deferral_reason` in the record's frontmatter + and appends a dated `## Deferral` section, refusing a tag that is not the + current anchor, an empty reason, and a record that is not open or not + `major` or `critical`. `deferred_after` names the **anchor** — the tag the cut is measured from, not the version being derived — which is what makes the waiver single-use: at the next release the anchor moves and every waiver written against the old one diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 5352c41f8..aacb64ea6 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -173,6 +173,19 @@ Capture issues to the ledger; bare invocation is read-only status --source string surfacing channel: plan-review | impl-review | manual-test | review-followup | agent-finding | agent-observation | user-observation | drift-detection | memory-curation | managed-repo (default user-observation) ``` +#### `abcd capture defer` + +Carry an open major or critical record past the current release cut (writes deferred_after + deferral_reason; stays in open/) + +**Usage:** `abcd capture defer --after --reason [flags]` + +**Flags:** + +``` + --after string the current anchor: the newest vX.Y.Z release tag, which the cut measures from (required) + --reason string why the finding is carried past this cut rather than fixed (required) +``` + #### `abcd capture disposition` Answer one reading item (a separate record, keyed to the item) diff --git a/internal/core/capture/deferral.go b/internal/core/capture/deferral.go new file mode 100644 index 000000000..1bd3ffcad --- /dev/null +++ b/internal/core/capture/deferral.go @@ -0,0 +1,171 @@ +package capture + +import ( + "fmt" + "strings" + "time" + + "github.com/intentdriven/abcd/internal/core/changelog" + "github.com/intentdriven/abcd/internal/core/grounds" + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/termsafe" +) + +// DeferRequest carries a release-cut waiver for one open record +// (iss-2609181223260994): the anchor tag the finding is carried past and the +// reason it is. +type DeferRequest struct { + RepoRoot string + IssuesRoot string + ID string + // After is the anchor tag, which must be the checkout's newest release tag: + // the cut reads the waiver by string equality against its own base, and a + // waiver naming any other tag is one the cut refuses. + After string + // Reason is why the finding is carried past this cut rather than fixed. + Reason string +} + +// DeferResult reports a deferral in the shape a transition reports: the record, +// where it is, and the waiver written. The record stays in open/. +type DeferResult struct { + ID string `json:"id"` + Path string `json:"path"` + Status State `json:"status"` + DeferredAfter string `json:"deferred_after"` + DeferralReason string `json:"deferral_reason"` + Redacted int `json:"redacted,omitempty"` + Degraded string `json:"redaction_degraded,omitempty"` +} + +// deferralNow is the clock the body section's date is read from; a test seam. +var deferralNow = time.Now + +// deferrableSeverities are the grades the release cut's finding guard blocks +// on, and so the only grades a waiver has anything to waive. +var deferrableSeverities = map[Severity]bool{SeverityMajor: true, SeverityCritical: true} + +// Defer writes the release cut's waiver pair — deferred_after and +// deferral_reason — onto an open major or critical record, and appends a dated +// `## Deferral` section to its body, so the waiver is a validated write rather +// than a hand edit of frontmatter (iss-2609181223260994). +// +// It refuses, with nothing written, everything the cut's own reader would not +// honour: a tag that is not the checkout's current anchor, an empty reason, a +// record that is not open, and a grade the guard never blocks on. A record +// already deferred past an earlier anchor is re-deferred: the pair is replaced +// and a new body section is appended, so the history of each deferral stays in +// the record. +func Defer(req DeferRequest) (DeferResult, error) { + repoRoot, issuesRoot, err := resolveRoots(req.RepoRoot, req.IssuesRoot) + if err != nil { + return DeferResult{}, err + } + if !reIssID.MatchString(req.ID) { + return DeferResult{}, fmt.Errorf("defer: invalid iss-N identifier: %q; nothing written", req.ID) + } + if !reShippedIn.MatchString(req.After) { + return DeferResult{}, fmt.Errorf("defer: --after %q is not a release tag (want vMAJOR.MINOR.PATCH); nothing written", req.After) + } + redReason, redacted, degraded := redactLedgerText(repoRoot, req.Reason) + reason := grounds.Fold(redReason) + if reason == "" { + return DeferResult{}, fmt.Errorf("defer: the reason is empty — a deferral with no stated reason records nothing; nothing written") + } + reason = termsafe.EncodeHiddenRunes(reason) + if err := mutationPreamble(repoRoot, issuesRoot); err != nil { + return DeferResult{}, err + } + + var result DeferResult + err = withLedgerLock(repoRoot, issuesRoot, func() error { + src, status, err := findIssue(issuesRoot, req.ID) + if err != nil { + return err + } + if status != StateOpen { + return fmt.Errorf("%w: %s is not open (it is in %s) — only an open record blocks a cut; nothing written", + ErrTransitionConflict, req.ID, status) + } + content, checksum, err := readWithChecksum(src) + if err != nil { + return err + } + fm, _, err := parseFrontmatterAndBody(content) + if err != nil { + return err + } + if sev := Severity(asString(fm["severity"])); !deferrableSeverities[sev] { + return fmt.Errorf("defer: %s is %s — only a major or critical record blocks a cut, so there is nothing to defer; nothing written", + req.ID, sev) + } + // The record is judged first, so a refusal about it names the record the + // checkout's ledger holds; the anchor is then the checkout's own. + if err := requireCurrentAnchor(repoRoot, req.After); err != nil { + return err + } + newContent, err := setScalarField(content, "deferred_after", rawScalar(req.After)) + if err != nil { + return err + } + if newContent, err = setScalarField(newContent, "deferral_reason", reason); err != nil { + return err + } + newContent = appendDeferralSection(newContent, deferralNow().UTC().Format("2006-01-02"), req.After, reason) + newFM, _, err := parseFrontmatterAndBody(newContent) + if err != nil { + return err + } + if err := validateStrict(newFM); err != nil { + return err + } + if err := validateInvariants(newFM, StateOpen, src); err != nil { + return err + } + _, current, err := readWithChecksum(src) + if err != nil { + return err + } + if current != checksum { + return fmt.Errorf("%w: %s changed since it was read", ErrChecksumMismatch, src) + } + if err := writeLedgerFile(repoRoot, issuesRoot, src, []byte(newContent)); err != nil { + return err + } + result = DeferResult{ID: req.ID, Path: fsutil.RepoRel(repoRoot, src), Status: StateOpen, + DeferredAfter: req.After, DeferralReason: reason, Redacted: redacted, Degraded: degraded} + return nil + }) + if err != nil { + return DeferResult{}, err + } + return result, nil +} + +// appendDeferralSection appends one dated `## Deferral` section to the record, +// the body half of a deferral's shape: the frontmatter pair is what the cut +// reads, and the section is what a reader of the record sees, one per cycle. +func appendDeferralSection(content, date, after, reason string) string { + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + return content + "\n## Deferral " + date + "\n\nDeferred past " + after + ": " + reason + "\n" +} + +// requireCurrentAnchor refuses a tag that is not the checkout's newest release +// tag — the base the cut measures from and compares deferred_after against. +func requireCurrentAnchor(repoRoot, after string) error { + anchor, found, err := changelog.LatestReleaseTag(repoRoot) + if err != nil { + return fmt.Errorf("defer: reading the release tags: %w", err) + } + if !found { + return fmt.Errorf("defer: this checkout has no release tag, so there is no cut to defer past; nothing written") + } + if after != anchor.Tag() { + return fmt.Errorf( + "defer: --after %s is not the current anchor %s — the cut honours a deferral past its own anchor only, and one past any other tag has lapsed or never applied; nothing written", + after, anchor.Tag()) + } + return nil +} diff --git a/internal/core/capture/deferral_test.go b/internal/core/capture/deferral_test.go new file mode 100644 index 000000000..12d3595d3 --- /dev/null +++ b/internal/core/capture/deferral_test.go @@ -0,0 +1,100 @@ +package capture + +import ( + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/intentdriven/abcd/internal/gittest" +) + +// deferralLedger is a git checkout tagged v0.1.0 — the anchor a deferral must +// name — holding one open major record and one open minor record. +func deferralLedger(t *testing.T) (repo, ir, major, minor string) { + t.Helper() + r := gittest.NewRepo(t) + r.Commit("root") + r.Git("tag", "v0.1.0") + repo = r.Root() + ir = filepath.Join(repo, LedgerRelPath) + mk := func(sev Severity, slug string) string { + res, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "a finding " + slug, + Severity: sev, Category: "bug", Source: "manual-test", Slug: slug, FoundDuring: "t"}) + if err != nil { + t.Fatal(err) + } + return res.ID + } + return repo, ir, mk(SeverityMajor, "big"), mk(SeverityMinor, "small") +} + +// TestDeferWritesTheWaiverPairAndABodySection is iss-2609181223260994: the +// release cut reads deferred_after and deferral_reason, and no verb wrote them, +// so a deferral was a hand edit that bypassed every validator. The verb writes +// both keys and a dated `## Deferral` body section, and the record stays open. +func TestDeferWritesTheWaiverPairAndABodySection(t *testing.T) { + repo, ir, major, _ := deferralLedger(t) + deferralNow = func() time.Time { return time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC) } + t.Cleanup(func() { deferralNow = time.Now }) + + res, err := Defer(DeferRequest{RepoRoot: repo, IssuesRoot: ir, ID: major, After: "v0.1.0", + Reason: "the fix needs the schema change landing next cycle"}) + if err != nil { + t.Fatal(err) + } + if res.Status != StateOpen || res.DeferredAfter != "v0.1.0" { + t.Fatalf("result = %+v", res) + } + raw := readRaw(t, ir, major) + for _, want := range []string{ + "\ndeferred_after: v0.1.0\n", + "\ndeferral_reason: \"the fix needs the schema change landing next cycle\"\n", + "\n## Deferral 2026-09-25\n\nDeferred past v0.1.0: the fix needs the schema change landing next cycle\n", + } { + if !strings.Contains(raw, want) { + t.Errorf("the deferred record lacks %q:\n%s", want, raw) + } + } + if iss := readIssue(t, ir, major); iss.Status != StateOpen { + t.Fatalf("a deferral moved the record to %s", iss.Status) + } +} + +// TestDeferRefusesWhatTheCutWouldNotHonour: every refusal the waiver's reader +// would apply at the cut, applied at the write instead, with nothing written. +func TestDeferRefusesWhatTheCutWouldNotHonour(t *testing.T) { + repo, ir, major, minor := deferralLedger(t) + resolved, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "fixed", + Severity: SeverityCritical, Category: "bug", Source: "manual-test", Slug: "done", FoundDuring: "t"}) + if err != nil { + t.Fatal(err) + } + if _, err := Resolve(ResolveRequest{RepoRoot: repo, IssuesRoot: ir, ID: resolved.ID, Resolution: "fixed", Impact: "fix"}); err != nil { + t.Fatal(err) + } + for _, c := range []struct { + name, id, after, reason, want string + }{ + {"not the anchor", major, "v0.0.9", "a reason that is long enough", "not the current anchor v0.1.0"}, + {"not a tag", major, "next", "a reason that is long enough", "not a release tag"}, + {"no reason", major, "v0.1.0", " ", "reason is empty"}, + {"a minor record", minor, "v0.1.0", "a reason that is long enough", "only a major or critical"}, + {"a resolved record", resolved.ID, "v0.1.0", "a reason that is long enough", "not open"}, + } { + t.Run(c.name, func(t *testing.T) { + before := readRaw(t, ir, c.id) + _, err := Defer(DeferRequest{RepoRoot: repo, IssuesRoot: ir, ID: c.id, After: c.after, Reason: c.reason}) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("want a refusal naming %q, got %v", c.want, err) + } + if readRaw(t, ir, c.id) != before { + t.Fatal("a refused deferral changed the record") + } + }) + } + if _, err := Defer(DeferRequest{RepoRoot: repo, IssuesRoot: ir, ID: "iss-1", After: "v0.1.0", Reason: "a reason that is long enough"}); !errors.Is(err, ErrUnknownIssueID) { + t.Fatalf("an unknown id: want ErrUnknownIssueID, got %v", err) + } +} diff --git a/internal/surface/cli/capture_root_test.go b/internal/surface/cli/capture_root_test.go index e74ef952a..693e3865a 100644 --- a/internal/surface/cli/capture_root_test.go +++ b/internal/surface/cli/capture_root_test.go @@ -281,6 +281,18 @@ func TestEveryCaptureVerbAddressesTheCheckoutLedger(t *testing.T) { } }, }, + "defer": { + // The seeded records are minor, so the checkout's ledger answers with + // the grade refusal; a subdirectory ledger would not know the id at all. + args: func(ids []string, _ string) []string { + return []string{"capture", "defer", ids[0], "--after", "v0.1.0", "--reason", "carried past this cut", "--json"} + }, + check: func(t *testing.T, _ string, ids []string, _ string, out []byte, err error) { + if err == nil || !strings.Contains(string(out)+err.Error(), "only a major or critical record") { + t.Fatalf("capture defer %s from the subdirectory did not read the checkout's record: %v\n%s", ids[0], err, out) + } + }, + }, "disposition": { args: func(_ []string, item string) []string { return []string{"capture", "disposition", item, "--state", "accepted", diff --git a/internal/surface/cli/capture_surface_test.go b/internal/surface/cli/capture_surface_test.go index c1dbd7754..036c28d00 100644 --- a/internal/surface/cli/capture_surface_test.go +++ b/internal/surface/cli/capture_surface_test.go @@ -5,10 +5,13 @@ import ( "encoding/json" "io/fs" "os" + "os/exec" "path/filepath" "regexp" "strings" "testing" + + "github.com/intentdriven/abcd/internal/gittest" ) // The three tests below are iss-29's acceptance corpus for the @@ -531,6 +534,40 @@ func TestCaptureSaysTheRecordIsUncommitted(t *testing.T) { } } +// TestCaptureDeferWritesTheWaiver is the surface half of iss-2609181223260994: +// the verb is reachable from the CLI, writes the waiver onto an open major +// record, and says the waiver lapses at the next re-anchor. +func TestCaptureDeferWritesTheWaiver(t *testing.T) { + repo := captureLedgerRepo(t) + gitCommitAt(t, repo, "root") + tag := exec.Command("git", "-C", repo, "tag", "v0.2.0") + tag.Env = gittest.Env(t) + if out, err := tag.CombinedOutput(); err != nil { + t.Fatalf("git tag: %v (%s)", err, out) + } + var rec struct { + ID string `json:"id"` + Path string `json:"path"` + } + if err := json.Unmarshal(runCLI(t, "capture", "a major finding to carry", "--severity", "major", "--json"), &rec); err != nil { + t.Fatal(err) + } + out := string(runCLI(t, "capture", "defer", rec.ID, "--after", "v0.2.0", "--reason", "the fix lands with the next schema")) + if !strings.Contains(out, rec.ID+" deferred past v0.2.0 (stays open)") || !strings.Contains(out, "lapses when the next release re-anchors") { + t.Fatalf("unexpected defer render:\n%s", out) + } + body, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(rec.Path))) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "deferred_after: v0.2.0") || !strings.Contains(string(body), "## Deferral ") { + t.Fatalf("the record does not carry the waiver:\n%s", body) + } + if _, err := runCLIErr(t, "capture", "defer", rec.ID, "--after", "v0.2.0"); err == nil { + t.Fatal("a deferral with no --reason must be refused") + } +} + // TestCaptureLapsedAtWritesTheGivenInstant pins the flag half of spc-60: the // instant handed to --lapsed-at is the instant committed to the record. The // record id is minted from the wall clock, so a surface that dropped, rounded or diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 495dc8261..5a9a5984e 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3814,6 +3814,39 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { "restamp how this record's text was produced: "+provenance.ModeList()+" (default: leave the record's existing stamp alone; refused on a record that predates disclosure)") captureCmd.AddCommand(wontfixCmd) + // defer — the release cut's waiver, written by a verb (iss-2609181223260994). + // The cut's finding guard reads deferred_after and deferral_reason; before + // this verb they were a hand edit of frontmatter that no validator saw. The + // record stays in open/: a deferral carries a finding past one cut, it + // neither fixes nor declines it. + var deferAfter, deferReason string + deferCmd := &cobra.Command{ + Use: "defer --after --reason ", + Short: "Carry an open major or critical record past the current release cut (writes deferred_after + deferral_reason; stays in open/)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + repoRoot, err := captureLedgerRoot(cmd) + if err != nil { + return err + } + res, err := capture.Defer(capture.DeferRequest{ + RepoRoot: repoRoot, ID: args[0], After: deferAfter, Reason: deferReason, + }) + if err != nil { + return err + } + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + fmt.Fprintf(w, "%s deferred past %s (stays %s) — %s\n", res.ID, res.DeferredAfter, res.Status, termsafe.Sanitize(res.Path)) + fmt.Fprintf(w, " reason: %s\n", termsafe.Sanitize(res.DeferralReason)) + fmt.Fprintf(w, " the waiver lapses when the next release re-anchors; renew it then, or fix the finding\n") + emitRedactionNote(w, res.Redacted, res.Degraded) + }) + }, + } + deferCmd.Flags().StringVar(&deferAfter, "after", "", "the current anchor: the newest vX.Y.Z release tag, which the cut measures from (required)") + deferCmd.Flags().StringVar(&deferReason, "reason", "", "why the finding is carried past this cut rather than fixed (required)") + captureCmd.AddCommand(deferCmd) + return captureCmd } From 7d9d14fe5e7625f49eb24f4c52a163e26d4393fd Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:22:16 +0100 Subject: [PATCH 22/36] =?UTF-8?q?chore:=20resolve=20iss-2609181223260994?= =?UTF-8?q?=20=E2=80=94=20capture=20defer=20writes=20the=20waiver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609181223260994 Assisted-by: Claude:claude-opus-5-5 --- ...rites-the-deferral-keys-a-release-cut-reads-the-pri.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609181223260994-no-verb-writes-the-deferral-keys-a-release-cut-reads-the-pri.md (79%) diff --git a/.abcd/work/issues/open/iss-2609181223260994-no-verb-writes-the-deferral-keys-a-release-cut-reads-the-pri.md b/.abcd/work/issues/resolved/iss-2609181223260994-no-verb-writes-the-deferral-keys-a-release-cut-reads-the-pri.md similarity index 79% rename from .abcd/work/issues/open/iss-2609181223260994-no-verb-writes-the-deferral-keys-a-release-cut-reads-the-pri.md rename to .abcd/work/issues/resolved/iss-2609181223260994-no-verb-writes-the-deferral-keys-a-release-cut-reads-the-pri.md index 4c4ae668a..d886bb16e 100644 --- a/.abcd/work/issues/open/iss-2609181223260994-no-verb-writes-the-deferral-keys-a-release-cut-reads-the-pri.md +++ b/.abcd/work/issues/resolved/iss-2609181223260994-no-verb-writes-the-deferral-keys-a-release-cut-reads-the-pri.md @@ -9,6 +9,10 @@ found_during: "Gropius managed-repo session gropiusllm-56, three release cuts by origin: researcher-authored production_mode: hand-written found_at: "internal/surface/cli/cli.go" +resolution: "capture defer --after --reason writes deferred_after and deferral_reason plus a dated Deferral body section, refusing a tag that is not the current anchor, an empty reason, a record not open, and a grade other than major or critical; wired from the CLI and commands/capture.md." +impact: additive +resolved_by: + commit: "d09f45e4" --- No verb writes the deferral keys a release cut reads. The principle pre-existing-is-not-a-defence names deferred_after and deferral_reason as the one sanctioned way past the release-cut guard for an open major or critical record, and changelog.GuardFindings reads them, but no capture sub-verb writes them: the ledger verbs are disposition, list, mentions, promote, resolve and wontfix, so a deferral is a hand edit of frontmatter that bypasses the validators every other transition carries (the anchor tag's shape, the reason's presence, the record being open and major). Relayed from the Gropius managed-repo session gropiusllm-56 on 2026-09-18 at v0.9.0, which wrote the two keys into six records by script in one day, across three release cuts. Wanted: abcd capture defer --after vX.Y.Z --reason "…", refusing a record that is not open, a tag that is not the current anchor, and an empty reason, and reporting the deferral in the same envelope shape as resolve. Note the gate that reads the keys lives in the launch verbs, which that repository cannot run (iss-2609061432214212), so the write path and the read path are missing together there; this record is the write half. @@ -20,3 +24,7 @@ plus `deferred_after` and `deferral_reason` inserted into the frontmatter by sed. The ask is unchanged, `abcd capture defer --after --reason "…"`, one-shot and lintable; note the body section is part of the shape the verb would have to write, not only the two keys. + +## Grounds + +- pursued: a deferral becomes a validated one-shot write instead of a hand edit; a deferral the verb accepts that the release cut's guard then refuses would show it wrong From 80042217f64d9a23da9dcefbce505d370bc7744c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:22:31 +0100 Subject: [PATCH 23/36] test(capture): a deferral the verb writes is one the release cut honours Runs changelog.GuardFindings over the committed record capture defer wrote, and asserts the finding is waived with its reason rather than blocking. Refs: iss-2609181223260994 Assisted-by: Claude:claude-opus-5-5 --- internal/core/capture/deferral_test.go | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/internal/core/capture/deferral_test.go b/internal/core/capture/deferral_test.go index 12d3595d3..d837add41 100644 --- a/internal/core/capture/deferral_test.go +++ b/internal/core/capture/deferral_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/intentdriven/abcd/internal/core/changelog" "github.com/intentdriven/abcd/internal/gittest" ) @@ -98,3 +99,31 @@ func TestDeferRefusesWhatTheCutWouldNotHonour(t *testing.T) { t.Fatalf("an unknown id: want ErrUnknownIssueID, got %v", err) } } + +// TestADeferralTheVerbWritesIsOneTheCutHonours closes the loop against the +// reader: the release cut's finding guard, run over the committed record the +// verb wrote, waives it with the stated reason instead of blocking. +func TestADeferralTheVerbWritesIsOneTheCutHonours(t *testing.T) { + r := gittest.NewRepo(t) + r.Commit("root") + r.Git("tag", "v0.1.0") + repo := r.Root() + ir := filepath.Join(repo, LedgerRelPath) + res, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "a finding the cut would block", + Severity: SeverityMajor, Category: "bug", Source: "manual-test", Slug: "blocker", FoundDuring: "t"}) + if err != nil { + t.Fatal(err) + } + if _, err := Defer(DeferRequest{RepoRoot: repo, IssuesRoot: ir, ID: res.ID, After: "v0.1.0", + Reason: "carried to the next cycle with the schema change"}); err != nil { + t.Fatal(err) + } + r.Commit("file and defer") + g, err := changelog.GuardFindings(repo, "v0.1.0") + if err != nil { + t.Fatal(err) + } + if g.Status != changelog.FindingGuardPassed || len(g.Waived) != 1 || g.Waived[0].ID != res.ID { + t.Fatalf("the cut did not honour the verb's deferral: %+v", g) + } +} From a5a2b7f588097d7194d427476895666c8f3094e7 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:35:19 +0100 Subject: [PATCH 24/36] feat(capture): every ledger verb names the checkout and branch it addressed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ledger is per worktree and said so nowhere: a record filed in one worktree read "not found" from another with no hint which ledger had been searched. Every capture verb now names it — one stderr line "abcd capture: ledger of on branch " in the plain render, written before the verb runs so a refusal carries it too, and a `ledger` member {checkout, branch} appended to the --json envelope (renderLedger). The record dispatcher does the same for an issue id. The checkout is home-relative where it can be; the branch comes from symbolic-ref, so an unborn branch is named too. The intent audit's half is captured separately as iss-2609251235119402: its envelope belongs to the intent surface. Refs: iss-2609202053570475, iss-2609251235119402 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 8 ++ .../development/brief/04-surfaces/08-abcd.md | 5 +- ...-reads-the-issue-ledger-its-issue-drift.md | 14 ++ commands/abcd.md | 5 +- commands/capture.md | 6 + internal/surface/cli/capture_surface_test.go | 77 +++++++++++ internal/surface/cli/cli.go | 121 ++++++++++++++++-- internal/surface/cli/cli_test.go | 4 +- 8 files changed, 225 insertions(+), 15 deletions(-) create mode 100644 .abcd/work/issues/open/iss-2609251235119402-abcd-intent-audit-reads-the-issue-ledger-its-issue-drift.md diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index df005c2d7..3af34998d 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -207,6 +207,14 @@ Two consequences follow, and both are stated to the caller rather than guessed. deliberate fixture or the residue of the defect above, and only the caller can tell those apart. Moving it would destroy the evidence of which it was. +Every verb also says which checkout's ledger it addressed, and the record +dispatcher says it for an issue id (iss-2609202053570475): one stderr line naming +the checkout and its branch in the plain render, and a `ledger` member with +`checkout` and `branch` in the machine-readable one. The checkout is written home-relative where +it can be. A record filed in another worktree is invisible here, and a refusal +that says "not found" without naming where it looked sends the reader to the +wrong conclusion. + ## 3. Ledger structure Frontmatter, per the issue-ledger schema in `internal/core/issueschema`, which diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index 82b7ace0d..1c6cb4113 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -45,7 +45,10 @@ form. and the concrete next move for its lifecycle state. Bare answers *what can I do*; the id form answers *what is this, and what is my next move* (spc-26, itd-121). A positional on the namespace root is not a `show` sub-verb, so the -form stays inside the naming discipline. +form stays inside the naming discipline. For an issue id it also names the +checkout and branch whose ledger it read, as every ledger verb does: a stderr +line in the plain render and a `ledger` member in the machine-readable one +(iss-2609202053570475). Any other positional is refused: the CLI exits **2** with `abcd: unknown command …` on stderr, which is the framework's usage-error convention. `abcd status` is diff --git a/.abcd/work/issues/open/iss-2609251235119402-abcd-intent-audit-reads-the-issue-ledger-its-issue-drift.md b/.abcd/work/issues/open/iss-2609251235119402-abcd-intent-audit-reads-the-issue-ledger-its-issue-drift.md new file mode 100644 index 000000000..05bc81c5e --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251235119402-abcd-intent-audit-reads-the-issue-ledger-its-issue-drift.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251235119402" +slug: "abcd-intent-audit-reads-the-issue-ledger-its-issue-drift" +severity: "nitpick" +category: "ux" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/cli" +--- + +abcd intent audit reads the issue ledger (its issue-drift form checks the promote join) without naming which checkout's ledger and branch it read. Decomposed out of iss-2609202053570475, which made every capture verb and the record dispatcher name the ledger they addressed: the audit's output is the intent-auditor verdict envelope, owned by the intent surface, so adding the member there is that surface's change. The helpers to reuse are ledgerIdentityOf and renderLedger in internal/surface/cli. diff --git a/commands/abcd.md b/commands/abcd.md index 6c2eaa63f..e9668004f 100644 --- a/commands/abcd.md +++ b/commands/abcd.md @@ -60,7 +60,10 @@ Summarise the `id`, `family`, `status`, `title`, `path`, the `links` edges present), and each entry in `next_moves` — the concrete lifecycle move (e.g. a draft intent points at the planning interview and `intent plan`; an open issue points at `capture promote` / `resolve` / `wontfix`; decisions are -read). A shape-matching id found in no store exits non-zero naming the stores +read). For an issue id the JSON also carries `ledger` — the `checkout` and +`branch` whose ledger was read — because the same id can sit in another +worktree's ledger in another state; name it when you report. A shape-matching id +found in no store exits non-zero naming the stores searched — unless a peer holds it (a sibling worktree or a local branch, see `/abcd:peers`), in which case the refusal names that peer's branch, path and folder instead; relay it, and do not recreate the record here. An issue whose diff --git a/commands/capture.md b/commands/capture.md index fcf9fbb0f..7b9085d68 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -21,6 +21,12 @@ names it on stderr and leaves it exactly where it is; report that line to the user, because the records under it reach no gate and no release cut, and only they can tell a deliberate fixture store from one a stray capture left behind. +Every verb also names the ledger it addressed: in the plain render, one stderr +line `abcd capture: ledger of on branch `; with `--json`, a +`ledger` member carrying `checkout` and `branch`. The ledger is per checkout, so +a record filed in another worktree is not found here; when a verb reports an id +missing, relay which checkout and branch it looked in. + ## Status (bare) To render recent captures and counts: diff --git a/internal/surface/cli/capture_surface_test.go b/internal/surface/cli/capture_surface_test.go index 036c28d00..0f2764a34 100644 --- a/internal/surface/cli/capture_surface_test.go +++ b/internal/surface/cli/capture_surface_test.go @@ -568,6 +568,44 @@ func TestCaptureDeferWritesTheWaiver(t *testing.T) { } } +// TestEveryCaptureVerbNamesTheLedgerItAddressed is iss-2609202053570475: the +// ledger is per checkout, so a record filed in one worktree is "not found" in +// another with nothing saying which ledger was read. Every capture verb names +// the checkout and branch it addressed — on stderr in the text render, as a +// `ledger` member in --json. +func TestEveryCaptureVerbNamesTheLedgerItAddressed(t *testing.T) { + repo := captureLedgerRepo(t) + gitCommitAt(t, repo, "root") + runCLI(t, "capture", "a first observation for the ledger", "--slug", "first") + + text := string(runCLI(t, "capture", "list", "--open")) + if !strings.Contains(text, "abcd capture: ledger of ") || !strings.Contains(text, "on branch main") { + t.Fatalf("the text render does not name the ledger it addressed:\n%s", text) + } + for _, args := range [][]string{ + {"capture", "--json"}, + {"capture", "list", "--open", "--json"}, + {"capture", "another observation for the ledger", "--json"}, + } { + var env struct { + Ledger struct { + Checkout string `json:"checkout"` + Branch string `json:"branch"` + } `json:"ledger"` + } + out := runCLI(t, args...) + if err := json.Unmarshal(out, &env); err != nil { + t.Fatalf("%v: not JSON: %v\n%s", args, err, out) + } + if env.Ledger.Branch != "main" || !strings.HasSuffix(env.Ledger.Checkout, filepath.Base(repo)) { + t.Fatalf("%v: ledger member = %+v, want the checkout %s on main", args, env.Ledger, filepath.Base(repo)) + } + if strings.Contains(string(out), "abcd capture: ledger of ") { + t.Fatalf("%v: the --json render also printed the text line", args) + } + } +} + // TestCaptureLapsedAtWritesTheGivenInstant pins the flag half of spc-60: the // instant handed to --lapsed-at is the instant committed to the record. The // record id is minted from the wall clock, so a surface that dropped, rounded or @@ -1229,3 +1267,42 @@ func TestCaptureFarMissProseStillWrites(t *testing.T) { t.Fatalf("prose after an unknown first word wrote %d issue(s), want 1", n) } } + +// withoutLedgerLine drops the stderr line every capture verb's text render +// writes naming the ledger it addressed, for a test whose harness merges stderr +// into the render it asserts on. +func withoutLedgerLine(s string) string { + var keep []string + for _, ln := range strings.SplitAfter(s, "\n") { + if !strings.HasPrefix(ln, "abcd capture: ledger of ") { + keep = append(keep, ln) + } + } + return strings.Join(keep, "") +} + +// TestRecordDispatcherNamesTheLedgerForAnIssue: `abcd iss-N` reads the ledger +// too, so it names the checkout and branch it read, like every capture verb +// (iss-2609202053570475). +func TestRecordDispatcherNamesTheLedgerForAnIssue(t *testing.T) { + repo := captureLedgerRepo(t) + gitCommitAt(t, repo, "root") + var rec struct { + ID string `json:"id"` + } + if err := json.Unmarshal(runCLI(t, "capture", "an observation to describe", "--json"), &rec); err != nil { + t.Fatal(err) + } + var env struct { + Ledger struct { + Branch string `json:"branch"` + } `json:"ledger"` + } + out := runCLI(t, rec.ID, "--json") + if err := json.Unmarshal(out, &env); err != nil || env.Ledger.Branch != "main" { + t.Fatalf("abcd %s --json names no ledger: %v\n%s", rec.ID, err, out) + } + if text := string(runCLI(t, rec.ID)); !strings.Contains(text, "abcd: ledger of ") { + t.Fatalf("abcd %s names no ledger in its text render:\n%s", rec.ID, text) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 5a9a5984e..a8d3713cf 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -7,6 +7,7 @@ package cli import ( "bufio" + "bytes" "encoding/json" "errors" "fmt" @@ -220,7 +221,24 @@ func NewRootCommand() *cobra.Command { // (itd-2609091416295622): the consult runs only now. return peerHeldRefusal(cwd, "", args[0], err) } - return render(cmd.OutOrStdout(), asJSON, d, func(w io.Writer) { + // An issue is read from this checkout's ledger, so the answer + // names that ledger as every capture verb does + // (iss-2609202053570475): the same id may sit in another + // worktree's ledger, in another state. + emit := render + if strings.HasPrefix(args[0], "iss-") { + if root, rerr := capture.LedgerRoot(cwd); rerr == nil { + if !asJSON { + id := ledgerIdentityOf(root) + fmt.Fprintf(cmd.ErrOrStderr(), "abcd: ledger of %s%s\n", + termsafe.Sanitize(id.Checkout), branchPhrase(id.Branch)) + } + emit = func(w io.Writer, asJSON bool, v any, text func(io.Writer)) error { + return renderLedger(w, asJSON, root, v, text) + } + } + } + return emit(cmd.OutOrStdout(), asJSON, d, func(w io.Writer) { // Title and link values come from record files a hostile // clone can shape — sanitise before the terminal. fmt.Fprintf(w, "%s (%s, %s) — %s\n", d.ID, d.Family, d.Status, termsafe.Sanitize(d.Title)) @@ -3155,9 +3173,88 @@ func captureLedgerRoot(cmd *cobra.Command) (string, error) { for _, note := range strayStoreNotes(cwd, root, capture.LedgerRelPath, "ledger") { fmt.Fprintf(cmd.ErrOrStderr(), "abcd capture: %s\n", termsafe.Sanitize(note)) } + // The ledger is per checkout, so every verb says which one it addressed + // (iss-2609202053570475): a record filed in another worktree is otherwise + // "not found" here with nothing naming where "here" is. The text render + // says it on stderr, before the verb runs, so a refusal carries it too; + // --json carries it as the envelope's `ledger` member instead + // (renderLedger). + if asJSON, _ := cmd.Flags().GetBool("json"); !asJSON { + id := ledgerIdentityOf(root) + fmt.Fprintf(cmd.ErrOrStderr(), "abcd capture: ledger of %s%s\n", + termsafe.Sanitize(id.Checkout), branchPhrase(id.Branch)) + } return root, nil } +// ledgerIdentity names the checkout whose ledger a verb addressed and the +// branch checked out there (iss-2609202053570475). The checkout is home- +// relative where it can be, so the line carries no developer-identity path. +type ledgerIdentity struct { + Checkout string `json:"checkout"` + Branch string `json:"branch"` +} + +// ledgerIdentityOf reads root's identity: its home-redacted path, and the +// branch git reports ("HEAD" when detached, "" when git cannot answer). +func ledgerIdentityOf(root string) ledgerIdentity { + // symbolic-ref answers on an unborn branch too, where rev-parse cannot; it + // fails only when HEAD is detached, which rev-parse then names. + branch, err := gitutil.Run(root, "symbolic-ref", "--quiet", "--short", "HEAD") + if err != nil { + if head, herr := gitutil.Run(root, "rev-parse", "--abbrev-ref", "HEAD"); herr == nil { + branch = head + } else { + branch = "" + } + } + return ledgerIdentity{Checkout: fsutil.RedactHome(root), Branch: branch} +} + +// branchPhrase renders the branch half of the identity line. +func branchPhrase(branch string) string { + switch branch { + case "": + return " (branch unknown)" + case "HEAD": + return " (detached HEAD)" + } + return " on branch " + termsafe.Sanitize(branch) +} + +// renderLedger is render for a capture verb: the --json envelope gains a +// `ledger` member naming the checkout and branch addressed, appended after the +// result's own members, and the text render is unchanged (captureLedgerRoot has +// already said it on stderr). +func renderLedger(w io.Writer, asJSON bool, root string, v any, text func(io.Writer)) error { + if !asJSON { + text(w) + return nil + } + body, err := json.Marshal(v) + if err != nil { + return err + } + ident, err := json.Marshal(ledgerIdentityOf(root)) + if err != nil { + return err + } + if n := len(body); n >= 2 && body[0] == '{' && body[n-1] == '}' { + sep := "," + if n == 2 { + sep = "" + } + body = append(append(append(body[:n-1:n-1], []byte(sep+`"ledger":`)...), ident...), '}') + } + var buf bytes.Buffer + if err := json.Indent(&buf, body, "", " "); err != nil { + return err + } + buf.WriteByte('\n') + _, err = w.Write(buf.Bytes()) + return err +} + // strayStoreNotes names a record store sitting BELOW the checkout root, between // the caller and it — the exact deposit an unresolved front door leaves behind // (iss-2609090951291524 for the ledger, iss-2609091707224329 for the decision @@ -3255,7 +3352,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if err != nil { return err } - return render(cmd.OutOrStdout(), *asJSON, board, func(w io.Writer) { + return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, board, func(w io.Writer) { // A refused record is in none of the three totals, so the header // counts it beside them (iss-2609120452071388): the reader sees // what the board excluded in the same line as what it counted. @@ -3396,7 +3493,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if err != nil { return err } - return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { fmt.Fprintf(w, "captured %s (%s) — %s\n", res.ID, res.Status, termsafe.Sanitize(res.Path)) // Folder membership is a status only once the file is committed // (iss-2609100508570527): say so at the write, where it is cheap. @@ -3456,7 +3553,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if err != nil { return err } - return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { for _, iss := range res.Issues { fmt.Fprintf(w, "%s %s %s %s%s\n", iss.ID, iss.Status, iss.Severity, iss.Slug, blockedNote(iss)) } @@ -3493,7 +3590,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if err != nil { return err } - return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { fmt.Fprintf(w, "%s: %d open record(s), %d commit(s) walked, %d possibly already fixed\n", termsafe.Sanitize(res.Ref), res.OpenRecords, res.Commits, len(res.Rows)) for _, row := range res.Rows { @@ -3545,7 +3642,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if err != nil { return groundsUsageError("resolve", err) } - return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { fmt.Fprintf(w, "%s %s -> %s — %s%s\n", res.ID, res.FromStatus, res.ToStatus, termsafe.Sanitize(res.Path), resolvedByNote(res.ResolvedBy)) emitRedactionNote(w, res.Redacted, res.Degraded) }) @@ -3599,7 +3696,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if err != nil { return err } - return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { list := "[]" if len(res.BlockedBy) > 0 { list = "[" + strings.Join(res.BlockedBy, ", ") + "]" @@ -3649,7 +3746,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if err != nil { return groundsUsageError("promote", err) } - return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { verb := "minted" if res.Linked { verb = "linked" @@ -3693,7 +3790,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if err != nil { return &exitError{Code: 2, Msg: "abcd capture migrate: " + err.Error()} } - return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { mode := "report only — nothing was written; re-run with --apply to write" if res.Applied { mode = "applied" @@ -3749,7 +3846,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if err != nil { return err } - return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { fmt.Fprintf(w, "%s %s %s (%s) — %s\n", res.ID, res.Item, res.State, res.Position, termsafe.Sanitize(res.Path)) if res.Redacted > 0 { @@ -3799,7 +3896,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if err != nil { return groundsUsageError("wontfix", err) } - return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { fmt.Fprintf(w, "%s %s -> %s — %s\n", res.ID, res.FromStatus, res.ToStatus, termsafe.Sanitize(res.Path)) emitRedactionNote(w, res.Redacted, res.Degraded) }) @@ -3835,7 +3932,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if err != nil { return err } - return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { fmt.Fprintf(w, "%s deferred past %s (stays %s) — %s\n", res.ID, res.DeferredAfter, res.Status, termsafe.Sanitize(res.Path)) fmt.Fprintf(w, " reason: %s\n", termsafe.Sanitize(res.DeferralReason)) fmt.Fprintf(w, " the waiver lapses when the next release re-anchors; renew it then, or fix the finding\n") diff --git a/internal/surface/cli/cli_test.go b/internal/surface/cli/cli_test.go index 24c1e2323..afec99157 100644 --- a/internal/surface/cli/cli_test.go +++ b/internal/surface/cli/cli_test.go @@ -660,7 +660,9 @@ func TestCaptureLinkWiredEndToEnd(t *testing.T) { } // Plain render: one line naming the id and the list after the write. - plain := string(runCLI(t, "capture", "link", r2.ID, "--unblock", r1.ID)) + // The harness merges stderr, where every capture verb names the ledger it + // addressed (iss-2609202053570475); the render under test is stdout's. + plain := withoutLedgerLine(string(runCLI(t, "capture", "link", r2.ID, "--unblock", r1.ID))) if n := strings.Count(strings.TrimRight(plain, "\n"), "\n"); n != 0 { t.Fatalf("plain render is not one line:\n%s", plain) } From bdbe55f01c6e72ca7a2c947d02696a0414d205e8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:35:22 +0100 Subject: [PATCH 25/36] =?UTF-8?q?chore:=20resolve=20iss-2609202053570475?= =?UTF-8?q?=20=E2=80=94=20ledger=20verbs=20name=20their=20checkout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609202053570475 Assisted-by: Claude:claude-opus-5-5 --- ...ger-verb-should-name-which-checkout-s-ledger-it-add.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609202053570475-every-ledger-verb-should-name-which-checkout-s-ledger-it-add.md (71%) diff --git a/.abcd/work/issues/open/iss-2609202053570475-every-ledger-verb-should-name-which-checkout-s-ledger-it-add.md b/.abcd/work/issues/resolved/iss-2609202053570475-every-ledger-verb-should-name-which-checkout-s-ledger-it-add.md similarity index 71% rename from .abcd/work/issues/open/iss-2609202053570475-every-ledger-verb-should-name-which-checkout-s-ledger-it-add.md rename to .abcd/work/issues/resolved/iss-2609202053570475-every-ledger-verb-should-name-which-checkout-s-ledger-it-add.md index 5485016b1..ac986c79d 100644 --- a/.abcd/work/issues/open/iss-2609202053570475-every-ledger-verb-should-name-which-checkout-s-ledger-it-add.md +++ b/.abcd/work/issues/resolved/iss-2609202053570475-every-ledger-verb-should-name-which-checkout-s-ledger-it-add.md @@ -9,6 +9,14 @@ found_during: "record-discipline review of the peer-listing draft, 2026-09-20" origin: researcher-authored production_mode: hand-written found_at: "internal/surface/cli/cli.go" +resolution: "Every capture verb names the checkout and branch whose ledger it addressed, on stderr in the plain render and as a ledger member in --json, and the record dispatcher does the same for an issue id; the intent audit half is decomposed to iss-2609251235119402." +impact: additive +resolved_by: + commit: "a5a2b7f5" --- Every ledger verb should name which checkout's ledger it addressed. The ledger is per worktree and says so nowhere: a capture filed in one worktree is invisible to capture resolve in another, and the verb's refusal reads "not found" with no hint that another checkout holds the record. The cheapest always-on remedy, asked for in the 2026-09-18 corroboration on iss-2609020716570699 and routed out of the peer-listing intent by its record-discipline review on 2026-09-20 as a cross-cutting change: one line from every ledger verb (capture, resolve, wontfix, promote, list, the record dispatcher, intent audit) naming the checkout root and branch whose ledger it read or wrote, on stderr in the text render and as a member in --json. Distinct from the peer listing (itd-2609091416295622), which reads other checkouts; this is the verb saying which one it is in. + +## Grounds + +- pursued: a reader told an id is not found also learns which checkout and branch were searched; a capture verb whose output names no ledger would show it wrong From a60cc4c5811f1288189137cdccb3a9bfaebedbb9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:44:04 +0100 Subject: [PATCH 26/36] feat(capture): say when a capture names no location in this checkout A capture without --found-at is legitimate and stays so, but it was written silently, and it is the shape all nine misfiled installer records of iss-2609120511058115 had: the path guard refuses a path-shaped found_at absent from the checkout and has nothing to judge when there is none. The verb now says, on stderr, that the record names no location in this checkout, and --json carries no_location; the exit code and the record written are unchanged, and a capture naming a location says nothing of the kind. Refs: iss-2609231156260287, iss-2609120511058115 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 4 +++ commands/capture.md | 6 ++++- internal/core/capture/capture.go | 5 ++++ internal/core/capture/workflow.go | 1 + internal/surface/cli/capture_surface_test.go | 25 +++++++++++++++++++ internal/surface/cli/cli.go | 6 +++++ 6 files changed, 46 insertions(+), 1 deletion(-) diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index 3af34998d..c02e0310d 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -73,6 +73,10 @@ hold is the mechanical sign of a finding filed in the wrong place (iss-2609120511058115). A conceptual location, meaning anything that is not a lone path token, and an absent value are written as given. The check is made at capture only, so a record keeps the path it named when the tree later moves. +An absent location is written as given and is not refused, but the verb says the +record names no location in this checkout, so nothing ties it to the repository +it is filed into: that is a nudge, not a gate, and it is the shape every +misfiled record behind iss-2609120511058115 had (iss-2609231156260287). One flag belongs to one category: the lapse-instant flag carries the RFC 3339 instant a recorded discipline gave way, for the `lapse` category, and it has no diff --git a/commands/capture.md b/commands/capture.md index 7b9085d68..652b16ef2 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -85,7 +85,11 @@ declared mode, else `hand-written`). Report the new `id`, `status`, and `path` f too whenever it is non-zero: it counts the spans rewritten before the text was written, and the user needs to know their wording was changed. When `uncommitted` is true, say that the record is not in git yet: until it is -committed no other branch, worktree or gate can see it. +committed no other branch, worktree or gate can see it. When `no_location` is +true, no `--found-at` was given: the record is written all the same, and the +verb says (on stderr in the plain render) that it names no location in this +checkout. Relay that, because a finding about another repository has exactly +that shape. `--category lapse` takes `--lapsed-at`, which has no default: a lapse capture that omits it records no instant, never the write-up time. The refusal on an diff --git a/internal/core/capture/capture.go b/internal/core/capture/capture.go index 2cd379ad5..889bfdb81 100644 --- a/internal/core/capture/capture.go +++ b/internal/core/capture/capture.go @@ -182,6 +182,11 @@ type CaptureResult struct { // It exists so the write can SAY the record reaches no other branch and no // gate until it is committed (iss-2609100508570527). Uncommitted bool `json:"uncommitted,omitempty"` + // NoLocation is true when the capture named no found_at: legitimate for a + // conceptual finding, and still worth saying, because nothing then ties the + // record to the repository it is filed into — the shape every misfiled + // record of iss-2609120511058115 had (iss-2609231156260287). + NoLocation bool `json:"no_location,omitempty"` } // ResolveRequest moves an open issue to resolved/. diff --git a/internal/core/capture/workflow.go b/internal/core/capture/workflow.go index 0c72be8f6..8597cac3c 100644 --- a/internal/core/capture/workflow.go +++ b/internal/core/capture/workflow.go @@ -119,6 +119,7 @@ func Capture(req CaptureRequest) (CaptureResult, error) { return CaptureResult{}, err } result.Redacted, result.Degraded = redacted, degraded + result.NoLocation = strings.TrimSpace(req.FoundAt) == "" // Machine output carries a repo-relative locator, never an absolute // developer-identity path (iss-81). result.Path = fsutil.RepoRel(repoRoot, result.Path) diff --git a/internal/surface/cli/capture_surface_test.go b/internal/surface/cli/capture_surface_test.go index 0f2764a34..5a9231b44 100644 --- a/internal/surface/cli/capture_surface_test.go +++ b/internal/surface/cli/capture_surface_test.go @@ -1306,3 +1306,28 @@ func TestRecordDispatcherNamesTheLedgerForAnIssue(t *testing.T) { t.Fatalf("abcd %s names no ledger in its text render:\n%s", rec.ID, text) } } + +// TestCaptureWithoutFoundAtSaysNoLocationWasNamed is iss-2609231156260287: +// a capture with no --found-at is legitimate and is written as before, but the +// verb says the record names no location in this checkout — on stderr, and as +// no_location in --json. A capture naming one says nothing of the kind. +func TestCaptureWithoutFoundAtSaysNoLocationWasNamed(t *testing.T) { + captureLedgerRepo(t) + out := string(runCLI(t, "capture", "a process observation with no file", "--slug", "nowhere")) + if !strings.Contains(out, "names no location in this checkout") { + t.Fatalf("a capture with no --found-at did not say so:\n%s", out) + } + var res struct { + NoLocation bool `json:"no_location"` + } + if err := json.Unmarshal(runCLI(t, "capture", "another process observation here", "--json"), &res); err != nil || !res.NoLocation { + t.Fatalf("--json does not carry no_location: %v %+v", err, res) + } + if err := os.WriteFile("placed.go", []byte("package x\n"), 0o644); err != nil { + t.Fatal(err) + } + with := string(runCLI(t, "capture", "a finding about one file", "--found-at", "placed.go")) + if strings.Contains(with, "names no location") { + t.Fatalf("a capture naming a location still said it named none:\n%s", with) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index a8d3713cf..4c517bb44 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3500,6 +3500,12 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if res.Uncommitted { fmt.Fprintf(w, " uncommitted: the record is not in git yet — commit it, or no other branch, worktree or gate will see it\n") } + // A nudge, never a refusal (iss-2609231156260287): a capture with + // no location is legitimate, and it is also the shape a finding + // filed into the wrong repository has. + if res.NoLocation { + fmt.Fprintf(cmd.ErrOrStderr(), "abcd capture: no --found-at given — the record names no location in this checkout, so nothing ties it to the repository it is filed into\n") + } // Redaction alters what the caller filed, so it is never silent: the // text on disk differs from the text handed in, and only the caller // can judge whether the redacted record still says what they meant. From ef461dbf087dd7317545b2ac63fa4d0e9f5fed5b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:44:05 +0100 Subject: [PATCH 27/36] =?UTF-8?q?chore:=20resolve=20iss-2609231156260287?= =?UTF-8?q?=20=E2=80=94=20location-less=20capture=20says=20so?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609231156260287 Assisted-by: Claude:claude-opus-5-5 --- ...ure-writes-a-record-with-an-empty-found-at-silently.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609231156260287-abcd-capture-writes-a-record-with-an-empty-found-at-silently.md (74%) diff --git a/.abcd/work/issues/open/iss-2609231156260287-abcd-capture-writes-a-record-with-an-empty-found-at-silently.md b/.abcd/work/issues/resolved/iss-2609231156260287-abcd-capture-writes-a-record-with-an-empty-found-at-silently.md similarity index 74% rename from .abcd/work/issues/open/iss-2609231156260287-abcd-capture-writes-a-record-with-an-empty-found-at-silently.md rename to .abcd/work/issues/resolved/iss-2609231156260287-abcd-capture-writes-a-record-with-an-empty-found-at-silently.md index 154db64b9..7cdb0150d 100644 --- a/.abcd/work/issues/open/iss-2609231156260287-abcd-capture-writes-a-record-with-an-empty-found-at-silently.md +++ b/.abcd/work/issues/resolved/iss-2609231156260287-abcd-capture-writes-a-record-with-an-empty-found-at-silently.md @@ -9,6 +9,14 @@ found_during: "autonomous run 2026-09-23" origin: researcher-authored production_mode: hand-written found_at: "internal/core/capture/validate.go" +resolution: "A capture without --found-at is written as before and says on stderr that the record names no location in this checkout, with no_location in --json; a capture naming a location prints no such line." +impact: additive +resolved_by: + commit: "a60cc4c5" --- abcd capture writes a record with an empty found_at silently, and that is the shape all nine misfiled installer records of iss-2609120511058115 had: the guard that closed that record refuses a path-shaped found_at absent from the checkout, which none of the nine carried, so the batch that motivated it would still file today. A capture without found_at is legitimate (a conceptual finding, a process observation) and must stay legitimate, so the remedy the record floated is a nudge rather than a refusal: when --found-at is absent, the verb says the record names no location in this checkout and so nothing ties it to the repository it is filed into (on stderr, and as a field in --json), without changing the exit code or what is written. Acceptance: given a capture with no --found-at, when the verb runs, then the record is written as today and the output says no location was named; given one with a found_at, no such line appears. + +## Grounds + +- pursued: a finding filed with no location is surfaced to its author at write time; a location-less capture that prints nothing would show it wrong From e2abfdf8bc8c3dda0552a86ec1ca0e6e88190396 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:38:21 +0100 Subject: [PATCH 28/36] fix(capture): an unreadable record bucket is reported, not read as empty findRecordFile, the existence probe behind resolve --intent/--spec and promote --intent, treated every bucket read failure as an empty bucket. An unreadable bucket was answered "not found in the store", and a symlinked bucket was followed out of the store, so a resolve could stamp provenance naming an intent that lives outside the checkout. An absent bucket stays soft; a symlinked or non-directory bucket is refused as path-unsafe, and any other read failure is returned as itself. Refs: iss-260 Assisted-by: Claude:claude-opus-5-5 --- internal/core/capture/promote.go | 10 +++- internal/core/capture/recordref.go | 28 +++++++++-- internal/core/capture/recordref_test.go | 46 +++++++++++++++++++ .../core/capture/resolve_provenance_test.go | 8 ++-- internal/core/capture/workflow.go | 8 +++- 5 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 internal/core/capture/recordref_test.go diff --git a/internal/core/capture/promote.go b/internal/core/capture/promote.go index f64355ec5..0190ffd88 100644 --- a/internal/core/capture/promote.go +++ b/internal/core/capture/promote.go @@ -192,7 +192,10 @@ func Promote(req PromoteRequest) (PromoteResult, error) { if !reItdID.MatchString(req.LinkIntent) { return PromoteResult{}, fmt.Errorf("invalid itd-N identifier: %q", req.LinkIntent) } - rel, ok := findRecordFile(repoRoot, intentStoreRelDirs(), req.LinkIntent) + rel, ok, err := findRecordFile(repoRoot, intentStoreRelDirs(), req.LinkIntent) + if err != nil { + return PromoteResult{}, fmt.Errorf("--intent %s: %w; nothing stamped", req.LinkIntent, err) + } if !ok { return PromoteResult{}, fmt.Errorf("%s not found in the intent store; nothing stamped", req.LinkIntent) } @@ -536,7 +539,10 @@ func promoteReadingItem(repoRoot, issuesRoot string, req PromoteRequest) (Promot if !reItdID.MatchString(req.LinkIntent) { return PromoteResult{}, fmt.Errorf("invalid itd-N identifier: %q", req.LinkIntent) } - rel, ok := findRecordFile(repoRoot, intentStoreRelDirs(), req.LinkIntent) + rel, ok, err := findRecordFile(repoRoot, intentStoreRelDirs(), req.LinkIntent) + if err != nil { + return PromoteResult{}, fmt.Errorf("--intent %s: %w; nothing stamped", req.LinkIntent, err) + } if !ok { return PromoteResult{}, fmt.Errorf("%s not found in the intent store; nothing stamped", req.LinkIntent) } diff --git a/internal/core/capture/recordref.go b/internal/core/capture/recordref.go index a14b0c35b..fe93be98f 100644 --- a/internal/core/capture/recordref.go +++ b/internal/core/capture/recordref.go @@ -1,6 +1,7 @@ package capture import ( + "fmt" "os" "path/filepath" @@ -37,13 +38,30 @@ func specStoreRelDirs() []string { // probe — no content is read (a sha's worth of validation lives with the // record's own store); the returned path is repo-relative. The caller is // expected to have regex-validated id before it reaches a path. -func findRecordFile(repoRoot string, relDirs []string, id string) (string, bool) { +// +// An ABSENT bucket is soft, like the ledger scan. Any other failure to read one +// is returned as the error it is (iss-260): a bucket that is a symlink is +// refused rather than followed out of the store, and an unreadable bucket is +// reported as unreadable, never as "not found in the store", which named the +// wrong cause and sent the operator looking for a record that may well exist. +func findRecordFile(repoRoot string, relDirs []string, id string) (string, bool, error) { exact := id + ".md" prefix := id + "-" for _, rel := range relDirs { - entries, err := os.ReadDir(filepath.Join(repoRoot, rel)) + dir := filepath.Join(repoRoot, rel) + fi, err := os.Lstat(dir) + if os.IsNotExist(err) { + continue + } + if err != nil { + return "", false, fmt.Errorf("cannot read the store bucket %s: %w", filepath.ToSlash(rel), err) + } + if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { + return "", false, fmt.Errorf("%w: the store bucket %s is not a real directory", ErrPathUnsafe, filepath.ToSlash(rel)) + } + entries, err := os.ReadDir(dir) if err != nil { - continue // absent store/bucket is soft, like the ledger scan + return "", false, fmt.Errorf("cannot read the store bucket %s: %w", filepath.ToSlash(rel), err) } for _, e := range entries { if e.IsDir() { @@ -51,9 +69,9 @@ func findRecordFile(repoRoot string, relDirs []string, id string) (string, bool) } n := e.Name() if n == exact || (len(n) > len(prefix) && n[:len(prefix)] == prefix && filepath.Ext(n) == ".md") { - return filepath.Join(rel, n), true + return filepath.Join(rel, n), true, nil } } } - return "", false + return "", false, nil } diff --git a/internal/core/capture/recordref_test.go b/internal/core/capture/recordref_test.go new file mode 100644 index 000000000..6ac3ac81c --- /dev/null +++ b/internal/core/capture/recordref_test.go @@ -0,0 +1,46 @@ +package capture + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/intent" +) + +// TestRecordProbeReportsAnUnreadableBucketAsItself is iss-260: the probe that +// resolve's --intent/--spec and promote's --intent share treated every bucket +// read failure as an empty bucket, so an unreadable bucket — here a symlinked +// one — was answered "not found in the store", which lies about the cause. An +// absent bucket stays soft; an unreadable one is reported as what it is. +func TestRecordProbeReportsAnUnreadableBucketAsItself(t *testing.T) { + repo, ir := ledger(t) + res, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "a finding", + Severity: SeverityMinor, Category: "bug", Source: "manual-test", Slug: "probe", FoundDuring: "t"}) + if err != nil { + t.Fatal(err) + } + elsewhere := t.TempDir() + if err := os.WriteFile(filepath.Join(elsewhere, "itd-7-x.md"), []byte("---\nid: itd-7\n---\n"), 0o644); err != nil { + t.Fatal(err) + } + intents := filepath.Join(repo, intent.IntentsRelDir) + if err := os.MkdirAll(intents, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(elsewhere, filepath.Join(intents, intent.BucketDrafts)); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + _, err = Resolve(ResolveRequest{RepoRoot: repo, IssuesRoot: ir, ID: res.ID, Resolution: "fixed", + Impact: "fix", ByIntent: "itd-7"}) + if err == nil { + t.Fatal("a resolve naming an intent behind a symlinked bucket succeeded") + } + if strings.Contains(err.Error(), "not found") { + t.Fatalf("an unreadable bucket was reported as not found: %v", err) + } + if !strings.Contains(err.Error(), intent.BucketDrafts) { + t.Fatalf("the refusal does not name the bucket it could not read: %v", err) + } +} diff --git a/internal/core/capture/resolve_provenance_test.go b/internal/core/capture/resolve_provenance_test.go index cb2aa7632..3460c9b60 100644 --- a/internal/core/capture/resolve_provenance_test.go +++ b/internal/core/capture/resolve_provenance_test.go @@ -177,17 +177,17 @@ func TestFindRecordFileProbe(t *testing.T) { t.Fatal(err) } } - if rel, ok := findRecordFile(repo, intentStoreRelDirs(), "itd-12"); !ok || !strings.Contains(rel, "shipped") { + if rel, ok, _ := findRecordFile(repo, intentStoreRelDirs(), "itd-12"); !ok || !strings.Contains(rel, "shipped") { t.Fatalf("probe missed itd-12 in shipped/: %q %v", rel, ok) } - if rel, ok := findRecordFile(repo, specStoreRelDirs(), "spc-9"); !ok || !strings.Contains(rel, "closed") { + if rel, ok, _ := findRecordFile(repo, specStoreRelDirs(), "spc-9"); !ok || !strings.Contains(rel, "closed") { t.Fatalf("probe missed bare spc-9.md in closed/: %q %v", rel, ok) } - if _, ok := findRecordFile(repo, intentStoreRelDirs(), "itd-1"); ok { + if _, ok, _ := findRecordFile(repo, intentStoreRelDirs(), "itd-1"); ok { t.Fatalf("probe found an absent id") } // itd-120 must not match itd-12's prefix rule. - if _, ok := findRecordFile(repo, intentStoreRelDirs(), "itd-120"); ok { + if _, ok, _ := findRecordFile(repo, intentStoreRelDirs(), "itd-120"); ok { t.Fatalf("probe prefix rule over-matched itd-120 against itd-12") } } diff --git a/internal/core/capture/workflow.go b/internal/core/capture/workflow.go index 8597cac3c..a30cfdc9f 100644 --- a/internal/core/capture/workflow.go +++ b/internal/core/capture/workflow.go @@ -339,7 +339,9 @@ func resolveProvenance(req ResolveRequest) (*ResolvedBy, error) { if !reItdID.MatchString(req.ByIntent) { return nil, fmt.Errorf("resolve: --intent %q does not match ^itd-[0-9]+$; nothing written", req.ByIntent) } - if _, ok := findRecordFile(repoRoot, intentStoreRelDirs(), req.ByIntent); !ok { + if _, ok, err := findRecordFile(repoRoot, intentStoreRelDirs(), req.ByIntent); err != nil { + return nil, fmt.Errorf("resolve: --intent %s: %w; nothing written", req.ByIntent, err) + } else if !ok { return nil, fmt.Errorf("resolve: --intent %s not found in the intent store; nothing written", req.ByIntent) } } @@ -347,7 +349,9 @@ func resolveProvenance(req ResolveRequest) (*ResolvedBy, error) { if !reSpcID.MatchString(req.BySpec) { return nil, fmt.Errorf("resolve: --spec %q does not match ^spc-[0-9]+$; nothing written", req.BySpec) } - if _, ok := findRecordFile(repoRoot, specStoreRelDirs(), req.BySpec); !ok { + if _, ok, err := findRecordFile(repoRoot, specStoreRelDirs(), req.BySpec); err != nil { + return nil, fmt.Errorf("resolve: --spec %s: %w; nothing written", req.BySpec, err) + } else if !ok { return nil, fmt.Errorf("resolve: --spec %s not found in the spec store; nothing written", req.BySpec) } } From 7b85e8a8b7b95bb66f4b8d493590dc94327bbfef Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:44:06 +0100 Subject: [PATCH 29/36] =?UTF-8?q?chore:=20resolve=20iss-260=20=E2=80=94=20?= =?UTF-8?q?unreadable=20record=20bucket=20reported=20as=20itself?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-260 Assisted-by: Claude:claude-opus-5-5 --- ...ss-260-record-probe-unreadable-bucket-diagnostic.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) rename .abcd/work/issues/{open => resolved}/iss-260-record-probe-unreadable-bucket-diagnostic.md (56%) diff --git a/.abcd/work/issues/open/iss-260-record-probe-unreadable-bucket-diagnostic.md b/.abcd/work/issues/resolved/iss-260-record-probe-unreadable-bucket-diagnostic.md similarity index 56% rename from .abcd/work/issues/open/iss-260-record-probe-unreadable-bucket-diagnostic.md rename to .abcd/work/issues/resolved/iss-260-record-probe-unreadable-bucket-diagnostic.md index 65423dc3a..a71036155 100644 --- a/.abcd/work/issues/open/iss-260-record-probe-unreadable-bucket-diagnostic.md +++ b/.abcd/work/issues/resolved/iss-260-record-probe-unreadable-bucket-diagnostic.md @@ -7,6 +7,14 @@ category: "ux" source: "impl-review" found_during: "spc-25 build, ruthless-reviewer note" found_at: "internal/core/capture/recordref.go" +resolution: "findRecordFile returns an error for a bucket it cannot read: a symlinked or non-directory bucket is refused as path-unsafe instead of being followed, any other read failure is reported as itself, and only an absent bucket stays soft; resolve and promote relay it with nothing written." +impact: fix +resolved_by: + commit: "e2abfdf8" --- -findRecordFile treats any os.ReadDir failure as an empty bucket, so an unreadable (not absent) intent/spec bucket — mode 000, or a symlinked bucket dir — makes capture promote --intent and capture resolve --intent/--spec report 'not found in the store' instead of the actual I/O error; the old intent.Load path in promote link mode reported the read error and refused symlinked buckets. Still fail-closed (nothing written), but the diagnostic lies about the cause. \ No newline at end of file +findRecordFile treats any os.ReadDir failure as an empty bucket, so an unreadable (not absent) intent/spec bucket — mode 000, or a symlinked bucket dir — makes capture promote --intent and capture resolve --intent/--spec report 'not found in the store' instead of the actual I/O error; the old intent.Load path in promote link mode reported the read error and refused symlinked buckets. Still fail-closed (nothing written), but the diagnostic lies about the cause. + +## Grounds + +- pursued: an operator told a linked record is missing can trust that it is missing; a symlinked bucket still answered as not found or followed would show it wrong From 5d085cbc25948036b0c72bdaaec71e04d965cecb Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:40:19 +0100 Subject: [PATCH 30/36] fix(capture): a closed-set refusal names the flag and its accepted set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unrecognised --severity, --category or --source was refused as "malformed frontmatter: invalid source ..." — frontmatter the caller never wrote, from a flag the message did not name. capture now judges the three request members first, before anything is written, and returns a FieldValueError naming the member, the value and the accepted set; the CLI renders it as "--source "ci-signal" is not accepted; accepted values: ..." with exit 2. Refs: iss-2608290810037524 Assisted-by: Claude:claude-opus-5-5 --- commands/capture.md | 5 +++- internal/core/capture/capture.go | 17 ++++++++++++ internal/core/capture/workflow.go | 23 ++++++++++++++++ internal/core/capture/workflow_test.go | 12 +++++--- internal/surface/cli/capture_surface_test.go | 29 ++++++++++++++++++++ internal/surface/cli/cli.go | 7 +++++ 6 files changed, 88 insertions(+), 5 deletions(-) diff --git a/commands/capture.md b/commands/capture.md index 652b16ef2..0b4907ea0 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -81,7 +81,10 @@ write-up), `--slug` (overrides the slug derived from the text), `--blocked-by` the ledger, and an edge to a record captured later is written afterwards with `link`, below), `--production-mode` (`hand-written|dictated-and-formatted|scribe-transcribed`, default: the repo's -declared mode, else `hand-written`). Report the new `id`, `status`, and `path` from the JSON. Report `redacted` +declared mode, else `hand-written`). `--severity`, `--category` and `--source` +are closed sets, and their help names every member; a value outside one is +refused (exit 2, nothing written) with a message naming the flag and the values +it accepts, so relay the set and pick from it rather than guessing again. Report the new `id`, `status`, and `path` from the JSON. Report `redacted` too whenever it is non-zero: it counts the spans rewritten before the text was written, and the user needs to know their wording was changed. When `uncommitted` is true, say that the record is not in git yet: until it is diff --git a/internal/core/capture/capture.go b/internal/core/capture/capture.go index 889bfdb81..fb5250c32 100644 --- a/internal/core/capture/capture.go +++ b/internal/core/capture/capture.go @@ -16,6 +16,7 @@ package capture import ( "errors" + "fmt" "regexp" "github.com/intentdriven/abcd/internal/core/issueschema" @@ -334,6 +335,22 @@ type StatusResult struct { Skipped []SkipRecord `json:"skipped"` } +// FieldValueError is a capture request member outside its closed vocabulary. +// It names the member, the value and the accepted set, and nothing about +// frontmatter: the value came from the caller's request, not from a record, +// so the refusal speaks about the request (iss-2608290810037524). A front door +// maps Field to its own spelling of the input — the CLI's flag of the same +// name. +type FieldValueError struct { + Field string + Value string + Accepted []string +} + +func (e *FieldValueError) Error() string { + return fmt.Sprintf("capture: %s %q is not accepted; %s (nothing written)", e.Field, e.Value, acceptedValues(e.Accepted)) +} + // Sentinel errors the surface maps to exit codes and messages. Core never // prints them. var ( diff --git a/internal/core/capture/workflow.go b/internal/core/capture/workflow.go index a30cfdc9f..bef2e182b 100644 --- a/internal/core/capture/workflow.go +++ b/internal/core/capture/workflow.go @@ -11,6 +11,7 @@ import ( "github.com/intentdriven/abcd/internal/core/changelog" "github.com/intentdriven/abcd/internal/core/grounds" + "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/provenance" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/termsafe" @@ -41,6 +42,13 @@ func Capture(req CaptureRequest) (CaptureResult, error) { if err != nil { return CaptureResult{}, err } + // The closed enumerations are judged as REQUEST members first, so a value + // the caller passed is refused in terms of the request and its accepted set + // rather than as malformed frontmatter the caller never wrote + // (iss-2608290810037524). Nothing has been written yet. + if err := validateRequestEnums(req); err != nil { + return CaptureResult{}, err + } // A found_at that names a path must name one in THIS checkout // (iss-2609120511058115). Checked before the preamble, so a refused capture // writes nothing at all — not even the ledger directories. @@ -132,6 +140,21 @@ func Capture(req CaptureRequest) (CaptureResult, error) { return result, nil } +// validateRequestEnums refuses a severity, category or source outside its +// closed vocabulary, naming the member and the accepted set — the same sets, +// from core/issueschema, the record validator reads. +func validateRequestEnums(req CaptureRequest) error { + switch { + case !validSeverities[req.Severity]: + return &FieldValueError{Field: "severity", Value: string(req.Severity), Accepted: issueschema.Severities} + case !validCategories[req.Category]: + return &FieldValueError{Field: "category", Value: string(req.Category), Accepted: issueschema.Categories} + case !validSources[req.Source]: + return &FieldValueError{Field: "source", Value: string(req.Source), Accepted: issueschema.Sources} + } + return nil +} + func commitCapture(repoRoot, issuesRoot string, req CaptureRequest, issID, slug, placeholder string) (CaptureResult, error) { // The disclosure pair (itd-178). origin is DERIVED — a capture's text is // written directly rather than derived from another record or a reading diff --git a/internal/core/capture/workflow_test.go b/internal/core/capture/workflow_test.go index 91898f881..0bd3b0ee1 100644 --- a/internal/core/capture/workflow_test.go +++ b/internal/core/capture/workflow_test.go @@ -245,8 +245,11 @@ func TestCaptureRejectsBadEnumAndSweepsPlaceholder(t *testing.T) { RepoRoot: repo, IssuesRoot: ir, Text: "x", Severity: "bogus", Category: "bug", Source: "manual-test", Slug: "s", FoundDuring: "ctx", }) - if !errors.Is(err, ErrMalformedFrontmatter) { - t.Fatalf("want ErrMalformedFrontmatter, got %v", err) + // A request value outside its vocabulary is refused as the request member it + // is, never as malformed frontmatter (iss-2608290810037524). + var fv *FieldValueError + if !errors.As(err, &fv) || fv.Field != "severity" { + t.Fatalf("want a FieldValueError on severity, got %v", err) } entries, _ := os.ReadDir(filepath.Join(ir, "open")) for _, e := range entries { @@ -270,8 +273,9 @@ func TestCaptureAcceptsAgentObservationSourceAndRejectsBogus(t *testing.T) { RepoRoot: repo, IssuesRoot: ir, Text: "x", Severity: SeverityMinor, Category: "observation", Source: "made-up-source", Slug: "s2", FoundDuring: "ctx", }) - if !errors.Is(err, ErrMalformedFrontmatter) { - t.Fatalf("bogus source want ErrMalformedFrontmatter, got %v", err) + var fv *FieldValueError + if !errors.As(err, &fv) || fv.Field != "source" { + t.Fatalf("bogus source want a FieldValueError on source, got %v", err) } } diff --git a/internal/surface/cli/capture_surface_test.go b/internal/surface/cli/capture_surface_test.go index 5a9231b44..53bcae8cf 100644 --- a/internal/surface/cli/capture_surface_test.go +++ b/internal/surface/cli/capture_surface_test.go @@ -1331,3 +1331,32 @@ func TestCaptureWithoutFoundAtSaysNoLocationWasNamed(t *testing.T) { t.Fatalf("a capture naming a location still said it named none:\n%s", with) } } + +// TestCaptureEnumRefusalNamesTheFlagAndItsSet is iss-2608290810037524: a +// closed-set flag's refusal named the value and blamed "malformed +// frontmatter" — a layer the caller never wrote. It names the flag and the +// accepted set instead, for every closed enumeration on the capture path, and +// writes nothing. +func TestCaptureEnumRefusalNamesTheFlagAndItsSet(t *testing.T) { + repo := captureLedgerRepo(t) + for _, c := range []struct{ flag, value, member string }{ + {"--severity", "medium", "critical"}, + {"--category", "test-flake", "future-work-seed"}, + {"--source", "ci-signal", "agent-finding"}, + } { + out, err := runCLIErr(t, "capture", "a finding with a guessed value", c.flag, c.value) + if err == nil { + t.Fatalf("%s %s was accepted:\n%s", c.flag, c.value, out) + } + msg := err.Error() + string(out) + if !strings.Contains(msg, c.flag+" \""+c.value+"\"") || !strings.Contains(msg, c.member) { + t.Errorf("%s refusal does not name the flag and its accepted set: %s", c.flag, msg) + } + if strings.Contains(msg, "malformed frontmatter") { + t.Errorf("%s refusal blames frontmatter the caller never wrote: %s", c.flag, msg) + } + } + if n := ledgerIssueCount(t, repo); n != 0 { + t.Fatalf("refused captures wrote %d record(s)", n) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 4c517bb44..7f55c2e76 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3491,6 +3491,13 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { // record must carry. res, err := capture.Capture(req) if err != nil { + // A closed-set value is the caller's flag, so the refusal names + // the flag and the set it accepts (iss-2608290810037524). + var fv *capture.FieldValueError + if errors.As(err, &fv) { + return &exitError{Code: 2, Msg: fmt.Sprintf("abcd capture: --%s %q is not accepted; accepted values: %s (nothing captured)", + fv.Field, fv.Value, enumHelp(fv.Accepted))} + } return err } return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { From 8813ccf9c9c349a9d198b5e5102fb277c4bdb61f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:44:08 +0100 Subject: [PATCH 31/36] =?UTF-8?q?chore:=20resolve=20iss-2608290810037524?= =?UTF-8?q?=20=E2=80=94=20enum=20refusal=20names=20flag=20and=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2608290810037524 Assisted-by: Claude:claude-opus-5-5 --- ...re-verb-refuses-an-unrecognised-source-value-with-a.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2608290810037524-the-capture-verb-refuses-an-unrecognised-source-value-with-a.md (89%) diff --git a/.abcd/work/issues/open/iss-2608290810037524-the-capture-verb-refuses-an-unrecognised-source-value-with-a.md b/.abcd/work/issues/resolved/iss-2608290810037524-the-capture-verb-refuses-an-unrecognised-source-value-with-a.md similarity index 89% rename from .abcd/work/issues/open/iss-2608290810037524-the-capture-verb-refuses-an-unrecognised-source-value-with-a.md rename to .abcd/work/issues/resolved/iss-2608290810037524-the-capture-verb-refuses-an-unrecognised-source-value-with-a.md index cc6bf390f..1ffccbadb 100644 --- a/.abcd/work/issues/open/iss-2608290810037524-the-capture-verb-refuses-an-unrecognised-source-value-with-a.md +++ b/.abcd/work/issues/resolved/iss-2608290810037524-the-capture-verb-refuses-an-unrecognised-source-value-with-a.md @@ -7,6 +7,10 @@ category: "ux" source: "agent-observation" found_during: "intent-implementation-run" found_at: "internal/surface/cli" +resolution: "capture judges --severity, --category and --source as request members before anything is written, refusing an unknown value with a FieldValueError the CLI renders as the flag, the value and the accepted set, never as malformed frontmatter; the help already named each vocabulary." +impact: fix +resolved_by: + commit: "5d085cbc" --- The capture verb refuses an unrecognised source value with a message that names neither the offending flag nor the accepted set, and blames the wrong layer: an invalid source produces a malformed-frontmatter error quoting the value, when the value came from a command-line flag and never reached any frontmatter the caller wrote. The accepted values are discoverable only by grepping existing records. The flag's help text lists no enumeration either. The same shape likely applies to category and to any other closed-set flag on this path. A closed set should be named in the refusal and in the help text. @@ -56,3 +60,7 @@ stands. Note also that `test-flake` is in no vocabulary at all, so the list alone would have sent that session to pick a neighbour (`bug` or `observation`); whether the taxonomy wants a value for a flaky test is a separate question this record does not decide. + +## Grounds + +- pursued: an agent that guesses a vocabulary value recovers in one round trip from the refusal alone; a refusal still omitting the flag or the set would show it wrong From 48e9b4f444275f7c6304c903ebf6c99e574f7879 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:42:29 +0100 Subject: [PATCH 32/36] fix(capture): the restamp gate parses the origin rather than finding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restampField refused a restamp on a record with no origin but accepted one beside any origin at all, so a record carrying an out-of-vocabulary origin was restamped into a pair no command writes. It now asks provenance.ParseOrigin and refuses naming the value. The converse — a valid origin with no production_mode, completed into the pair by a restamp — is kept and pinned by a test, and stated in spc-56 and the command page as a deliberate write inside the lint's declared residual. Refs: iss-2608300941548519 Assisted-by: Claude:claude-opus-5-5 --- ...through-a-command-carries-its-origin-an.md | 9 +++ commands/capture.md | 8 +- internal/core/capture/restamp_origin_test.go | 80 +++++++++++++++++++ internal/core/capture/workflow.go | 14 +++- 4 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 internal/core/capture/restamp_origin_test.go diff --git a/.abcd/development/specs/closed/spc-56-every-record-written-through-a-command-carries-its-origin-an.md b/.abcd/development/specs/closed/spc-56-every-record-written-through-a-command-carries-its-origin-an.md index 1f232c224..753d5622f 100644 --- a/.abcd/development/specs/closed/spc-56-every-record-written-through-a-command-carries-its-origin-an.md +++ b/.abcd/development/specs/closed/spc-56-every-record-written-through-a-command-carries-its-origin-an.md @@ -99,6 +99,15 @@ remedy: re-run without `--production-mode`. The refusal is about the RESTAMP and never about the transition — an unstamped record stays resolvable, because forward-only population must not strand a record nobody can close. +The gate reads the `origin` through the vocabulary's parser, not as a +presence test (iss-2608300941548519): a record carrying an `origin` outside +the closed set refuses the restamp on the same terms, because writing a mode +beside it produces a pair no command writes. The converse is a deliberate +write inside the lint's declared residual: a record carrying a valid `origin` +and no `production_mode`, itself a `record_provenance` blocker, is repaired +into a clean pair by a restamp, since that pair is exactly what a command +writes. + **The attribution seam is extended, not duplicated.** `identity.Pin` gains an optional `ProductionMode` member; `LoadPin` validates it against the vocabulary and returns an error on an unknown value, exactly as it already errors on a diff --git a/commands/capture.md b/commands/capture.md index 0b4907ea0..9a25efc7d 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -125,9 +125,11 @@ absent flag takes the repo's declared default from `.abcd/config/identity.json`, falling back to `hand-written`. On `capture resolve` and `capture wontfix` the flag **restamps** the record — a resolution note is new text with its own mode — and an absent flag leaves the record's existing stamp alone. A restamp of a -record that predates disclosure (one carrying no `origin`) is refused before -anything is written, because the pair is written together or not at all; re-run -without the flag. Such a record still resolves normally. +record that predates disclosure (one carrying no `origin`), or of one whose +`origin` is outside the vocabulary, is refused before anything is written, +because the pair is written together or not at all; re-run without the flag. A +record carrying a valid `origin` and no `production_mode` is completed into the +pair by the restamp. Such a record still resolves normally. Neither key touches authorship: they are disclosure at field granularity, on the same footing as the `Assisted-by:` trailer at commit granularity. Population is diff --git a/internal/core/capture/restamp_origin_test.go b/internal/core/capture/restamp_origin_test.go new file mode 100644 index 000000000..16702dd17 --- /dev/null +++ b/internal/core/capture/restamp_origin_test.go @@ -0,0 +1,80 @@ +package capture + +import ( + "os" + "strings" + "testing" +) + +// rewriteDisclosure rewrites a record's origin line to origin, and drops the +// production_mode line when dropMode is set. +func rewriteDisclosure(t *testing.T, issuesRoot, id, origin string, dropMode bool) { + t.Helper() + src, _, err := findIssue(issuesRoot, id) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(src) + if err != nil { + t.Fatal(err) + } + var kept []string + for _, line := range strings.Split(string(data), "\n") { + switch { + case strings.HasPrefix(line, "origin:") && origin != "": + line = "origin: " + origin + case strings.HasPrefix(line, "production_mode:") && dropMode: + continue + } + kept = append(kept, line) + } + if err := os.WriteFile(src, []byte(strings.Join(kept, "\n")), 0o644); err != nil { + t.Fatal(err) + } +} + +// TestRestampRefusesAnOriginOutsideTheVocabulary is iss-2608300941548519's +// first half: the restamp gate tested that an origin was PRESENT, so a record +// carrying an out-of-vocabulary origin accepted a restamp and was written as a +// pair no command produces. The gate now asks the origin parser, which is what +// "the pair is written together or not at all" means literally. +func TestRestampRefusesAnOriginOutsideTheVocabulary(t *testing.T) { + repo, ir := ledger(t) + res, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: SeverityMinor, + Category: "bug", Source: "user-observation", FoundDuring: "t", Slug: "odd"}) + if err != nil { + t.Fatal(err) + } + rewriteDisclosure(t, ir, res.ID, "invented-by-hand", false) + before := readRaw(t, ir, res.ID) + _, err = Resolve(ResolveRequest{RepoRoot: repo, IssuesRoot: ir, ID: res.ID, Resolution: "fixed", + Impact: "fix", ProductionMode: "scribe-transcribed"}) + if err == nil || !strings.Contains(err.Error(), "invented-by-hand") { + t.Fatalf("a restamp over an out-of-vocabulary origin must be refused naming it, got %v", err) + } + if readRaw(t, ir, res.ID) != before { + t.Fatal("a refused restamp rewrote the record") + } +} + +// TestRestampOfALoneOriginCompletesThePair pins the second half as the stated +// behaviour: a record carrying a valid origin and no production_mode (itself a +// record_provenance blocker) is repaired into a clean pair by a restamp, which +// is a legal write — the pair a command writes. +func TestRestampOfALoneOriginCompletesThePair(t *testing.T) { + repo, ir := ledger(t) + res, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: SeverityMinor, + Category: "bug", Source: "user-observation", FoundDuring: "t", Slug: "lone"}) + if err != nil { + t.Fatal(err) + } + rewriteDisclosure(t, ir, res.ID, "", true) + if _, err := Resolve(ResolveRequest{RepoRoot: repo, IssuesRoot: ir, ID: res.ID, Resolution: "fixed", + Impact: "fix", ProductionMode: "scribe-transcribed"}); err != nil { + t.Fatal(err) + } + fm := readLedgerFrontmatter(t, ir, res.ID) + if fm["origin"] != "researcher-authored" || fm["production_mode"] != "scribe-transcribed" { + t.Fatalf("the restamp did not complete the pair: %v", fm) + } +} diff --git a/internal/core/capture/workflow.go b/internal/core/capture/workflow.go index bef2e182b..73367c269 100644 --- a/internal/core/capture/workflow.go +++ b/internal/core/capture/workflow.go @@ -460,11 +460,23 @@ func restampField(fm map[string]any, issID, mode string) ([]kv, error) { if mode == "" { return nil, nil } - if asString(fm[provenance.KeyOrigin]) == "" { + origin := asString(fm[provenance.KeyOrigin]) + if origin == "" { return nil, fmt.Errorf( "%s carries no %s, so it predates disclosure and there is nothing to restamp: the pair is written together or not at all, and a lone %s is a state no command produces (nothing written — re-run without --production-mode)", issID, provenance.KeyOrigin, provenance.KeyProductionMode) } + // The origin is PARSED, not merely found present (iss-2608300941548519): a + // restamp beside an origin outside the vocabulary writes a pair no command + // produces, which is the state this gate exists to keep a command from + // writing. A record carrying a valid origin and no production_mode is the + // other half of that question, and it is allowed: the restamp completes it + // into the pair a command writes. + if _, err := provenance.ParseOrigin(origin); err != nil { + return nil, fmt.Errorf( + "%s carries %s %q, which is outside the vocabulary, so a restamp would write a pair no command produces: correct the %s first (nothing written — or re-run without --production-mode): %w", + issID, provenance.KeyOrigin, origin, provenance.KeyOrigin, err) + } m, err := provenance.ParseMode(mode) if err != nil { return nil, err From 116bd7c281324c595ac9032d6a74fbf9a2a01aed Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:44:09 +0100 Subject: [PATCH 33/36] =?UTF-8?q?chore:=20resolve=20iss-2608300941548519?= =?UTF-8?q?=20=E2=80=94=20restamp=20gate=20parses=20the=20origin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2608300941548519 Assisted-by: Claude:claude-opus-5-5 --- ...-2608300941548519-itd-178-second-round-observations.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2608300941548519-itd-178-second-round-observations.md (62%) diff --git a/.abcd/work/issues/open/iss-2608300941548519-itd-178-second-round-observations.md b/.abcd/work/issues/resolved/iss-2608300941548519-itd-178-second-round-observations.md similarity index 62% rename from .abcd/work/issues/open/iss-2608300941548519-itd-178-second-round-observations.md rename to .abcd/work/issues/resolved/iss-2608300941548519-itd-178-second-round-observations.md index a5f263ac9..083196977 100644 --- a/.abcd/work/issues/open/iss-2608300941548519-itd-178-second-round-observations.md +++ b/.abcd/work/issues/resolved/iss-2608300941548519-itd-178-second-round-observations.md @@ -7,6 +7,14 @@ category: "inconsistency" source: "impl-review" found_during: "itd-178 second-round security review, 2026-08-30" found_at: "internal/core/capture/workflow.go (restampField)" +resolution: "The restamp gate calls provenance.ParseOrigin and refuses a restamp beside an out-of-vocabulary origin; the lone-origin repair is kept as a deliberate write, pinned by a test and stated in spc-56 and commands/capture.md." +impact: fix +resolved_by: + commit: "48e9b4f4" --- itd-178 second-round observations: the restamp gate tests origin presence, not validity, so a record with an out-of-vocabulary origin accepts a restamp (the origin stays invalid and the lint still reports it — harmless; calling the origin parser would make the intent literal); a record carrying a lone origin, itself a blocker, is silently repaired into a clean pair by a restamp — a legal write inside the lint's declared residual, worth a sentence in spc-56. + +## Grounds + +- pursued: no restamp writes a disclosure pair outside the vocabulary; a resolve --production-mode succeeding over an invented origin would show it wrong From 39998cc0fcdc90a41c1cce8db90d893f1c6bb386 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:24:04 +0100 Subject: [PATCH 34/36] chore: capture four follow-ups from the capture review Refs: iss-2609251823551349 Refs: iss-2609251823555125 Refs: iss-2609251823559111 Refs: iss-2609251823560369 Assisted-by: Claude:claude-opus-5-5 --- ...sposition-writes-disposition-grounds-and-exit.md | 13 +++++++++++++ ...ast-the-same-anchor-appends-a-second-deferral.md | 13 +++++++++++++ ...ture-ledger-s-os-root-escape-is-classified-by.md | 13 +++++++++++++ ...verbs-json-and-stderr-print-the-checkout-path.md | 13 +++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609251823551349-capture-disposition-writes-disposition-grounds-and-exit.md create mode 100644 .abcd/work/issues/open/iss-2609251823555125-capture-defer-past-the-same-anchor-appends-a-second-deferral.md create mode 100644 .abcd/work/issues/open/iss-2609251823559111-the-capture-ledger-s-os-root-escape-is-classified-by.md create mode 100644 .abcd/work/issues/open/iss-2609251823560369-the-capture-verbs-json-and-stderr-print-the-checkout-path.md diff --git a/.abcd/work/issues/open/iss-2609251823551349-capture-disposition-writes-disposition-grounds-and-exit.md b/.abcd/work/issues/open/iss-2609251823551349-capture-disposition-writes-disposition-grounds-and-exit.md new file mode 100644 index 000000000..54eb45b5b --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251823551349-capture-disposition-writes-disposition-grounds-and-exit.md @@ -0,0 +1,13 @@ +--- +schema_version: 1 +id: "iss-2609251823551349" +slug: "capture-disposition-writes-disposition-grounds-and-exit" +severity: "minor" +category: "security" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +--- + +capture disposition writes disposition_grounds and exit_condition redacted but never through termsafe.EncodeHiddenRunes (internal/core/capture/reading.go:471-479), so a bidi or zero-width rune in --exit-condition lands in the committed record verbatim, the class iss-2608301206073609 closed for other free-text writes (review-capture 2). diff --git a/.abcd/work/issues/open/iss-2609251823555125-capture-defer-past-the-same-anchor-appends-a-second-deferral.md b/.abcd/work/issues/open/iss-2609251823555125-capture-defer-past-the-same-anchor-appends-a-second-deferral.md new file mode 100644 index 000000000..fd8a2040d --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251823555125-capture-defer-past-the-same-anchor-appends-a-second-deferral.md @@ -0,0 +1,13 @@ +--- +schema_version: 1 +id: "iss-2609251823555125" +slug: "capture-defer-past-the-same-anchor-appends-a-second-deferral" +severity: "minor" +category: "bug" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +--- + +capture defer past the SAME anchor appends a second ## Deferral section (internal/core/capture/deferral.go:114), where the doc says one per cycle (review-capture 3). diff --git a/.abcd/work/issues/open/iss-2609251823559111-the-capture-ledger-s-os-root-escape-is-classified-by.md b/.abcd/work/issues/open/iss-2609251823559111-the-capture-ledger-s-os-root-escape-is-classified-by.md new file mode 100644 index 000000000..ef239c7b6 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251823559111-the-capture-ledger-s-os-root-escape-is-classified-by.md @@ -0,0 +1,13 @@ +--- +schema_version: 1 +id: "iss-2609251823559111" +slug: "the-capture-ledger-s-os-root-escape-is-classified-by" +severity: "minor" +category: "tech-debt" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +--- + +The capture ledger's os.Root escape is classified by matching the string 'path escapes from parent' (internal/core/capture/ledgerroot.go:59-64), and nothing pins the match: ledgerroot_test.go:62 and :97 assert only err != nil, so a Go release that rewords the message would silently degrade ErrPathUnsafe to a generic error (review-capture 1). Assert errors.Is(err, ErrPathUnsafe) in both race tests. diff --git a/.abcd/work/issues/open/iss-2609251823560369-the-capture-verbs-json-and-stderr-print-the-checkout-path.md b/.abcd/work/issues/open/iss-2609251823560369-the-capture-verbs-json-and-stderr-print-the-checkout-path.md new file mode 100644 index 000000000..c82162697 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251823560369-the-capture-verbs-json-and-stderr-print-the-checkout-path.md @@ -0,0 +1,13 @@ +--- +schema_version: 1 +id: "iss-2609251823560369" +slug: "the-capture-verbs-json-and-stderr-print-the-checkout-path" +severity: "minor" +category: "security" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +--- + +The capture verbs' --json and stderr print the checkout path through RedactHome only, so a checkout outside HOME is printed in full (internal/surface/cli/cli.go renderLedger), an absolute local path in output that may be pasted elsewhere; renderLedger also splices the ledger member by byte surgery on the trailing brace (review-capture 4). From 68deae03f210499a62d356dc67101b268c15caed Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:20:16 +0100 Subject: [PATCH 35/36] feat(surface): capture defer carries its sentence from the manifest The help-sentence manifest reached this branch with the merge of main, and its gate refused `abcd capture defer`, the one visible verb the lane added: TestEveryVisibleVerbCarriesItsSentenceEverywhere and TestTheFrameworkCommandsAreTheOnlyExemption both named it. The verb now takes its one sentence from internal/core/surface/sentences.go in the house shape and drops its cobra Short; the generated CLI reference and the surface snapshot are regenerated from it. Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/release/surface.json | 1 + docs/reference/cli/commands.md | 2 +- internal/core/surface/sentences.go | 2 ++ internal/surface/cli/cli.go | 5 ++--- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index cf931492f..63f9ddb7f 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -363,6 +363,7 @@ { "path": "abcd capture defer", "hidden": false, + "sentence": "Carry an open major or critical issue past one release cut: Writes deferred_after and deferral_reason; refuses a minor or nitpick issue, or an empty reason.", "flags": [ { "name": "after", diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 705560717..daf147f3e 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -175,7 +175,7 @@ File an issue from quoted text, or render the ledger's status bare: Writes one r #### `abcd capture defer` -Carry an open major or critical record past the current release cut (writes deferred_after + deferral_reason; stays in open/) +Carry an open major or critical issue past one release cut: Writes deferred_after and deferral_reason; refuses a minor or nitpick issue, or an empty reason. **Usage:** `abcd capture defer --after --reason [flags]` diff --git a/internal/core/surface/sentences.go b/internal/core/surface/sentences.go index fc86a685e..88275aca4 100644 --- a/internal/core/surface/sentences.go +++ b/internal/core/surface/sentences.go @@ -48,6 +48,8 @@ var sentences = map[string]string{ "abcd capture": "File an issue from quoted text, or render the ledger's status bare: " + "Writes one record under open/; refuses a lone word and any folder outside a checkout.", + "abcd capture defer": "Carry an open major or critical issue past one release cut: " + + "Writes deferred_after and deferral_reason; refuses a minor or nitpick issue, or an empty reason.", "abcd capture disposition": "Answer one reading item with a disposition record: " + "Writes the record keyed to the item; refuses a second answer without --supersedes.", "abcd capture link": "Add or remove blocked_by edges on an issue: " + diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 37cbeb12a..331876b57 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -4168,9 +4168,8 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { // neither fixes nor declines it. var deferAfter, deferReason string deferCmd := &cobra.Command{ - Use: "defer --after --reason ", - Short: "Carry an open major or critical record past the current release cut (writes deferred_after + deferral_reason; stays in open/)", - Args: cobra.ExactArgs(1), + Use: "defer --after --reason ", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { repoRoot, err := captureLedgerRoot(cmd) if err != nil { From 9b7743350db8e190444837f37e3bcee5be94f5ac Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:21:18 +0100 Subject: [PATCH 36/36] chore: recalibrate the reading windows at the merged tip Merging main (85a10e1b) into the capture lane, whose fixes grow internal/core/capture and its tests, grew the tree-bounded reading objects. Each window is re-measured by dry run over a clean clone of the merged tip 68deae03 against the rule the entries state, the smallest ten-thousand boundary leaving at least one per cent headroom. Widening measures 1,040,187, past its 1,040,000 declaration, so it moves to 1,060,000 (1.90 per cent); entailment measures 344,124, 1.71 per cent under 350,000, so its declaration stays and only its measured fields move; detection measures 1,049,223, 0.07 per cent under its 1,050,000 declaration, so it moves to 1,060,000 (1.03 per cent). Comparative is bounded by the widening run it is handed, not by the tree, and is exempt by name. Refs: iss-2609251455354719 Assisted-by: Claude:claude-opus-5-5 --- .abcd/config/reading-presets.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.abcd/config/reading-presets.json b/.abcd/config/reading-presets.json index 87bb306ea..0d38242bc 100644 --- a/.abcd/config/reading-presets.json +++ b/.abcd/config/reading-presets.json @@ -60,10 +60,10 @@ "test" ], "window": { - "tokens_est": 1040000, - "measured_tokens_est": 1021262, - "measured_bytes": 3931862, - "measured_at": "1dff258e9ea7ae29841faaefc47966913fbbd954" + "tokens_est": 1060000, + "measured_tokens_est": 1040187, + "measured_bytes": 4004721, + "measured_at": "68deae03f210499a62d356dc67101b268c15caed" } }, "entailment": { @@ -133,9 +133,9 @@ ], "window": { "tokens_est": 350000, - "measured_tokens_est": 343062, - "measured_bytes": 1320792, - "measured_at": "1dff258e9ea7ae29841faaefc47966913fbbd954" + "measured_tokens_est": 344124, + "measured_bytes": 1324879, + "measured_at": "68deae03f210499a62d356dc67101b268c15caed" } }, "comparative": { @@ -216,10 +216,10 @@ "test" ], "window": { - "tokens_est": 1050000, - "measured_tokens_est": 1030298, - "measured_bytes": 3966650, - "measured_at": "1dff258e9ea7ae29841faaefc47966913fbbd954" + "tokens_est": 1060000, + "measured_tokens_est": 1049223, + "measured_bytes": 4039509, + "measured_at": "68deae03f210499a62d356dc67101b268c15caed" } } }