From 62d1ab56f5e573564bc51ca37ffc67a6ae45ffe4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:25:54 +0100 Subject: [PATCH 01/69] fix(guard): read a default's word, deep alternatives and a substring's root rm-rf-root-or-home compared each operand with one written spelling, the variable, so `rm -rf ${DIR:-$HOME}` and `rm -rf ${DIR:-/}` allowed though they delete the home or the root with DIR unset. A word's written spelling (segment.spelled, varSite.texts) is now the set of texts it can print: - a default or an assignment (`-`, `:-`, `=`, `:=`) is the variable and every text its word prints, read through spellWord, also at the first operator after a subscript's `]` as bash 3.2 reads it (`${X[0]]-$HOME}`); - an alternative is its word's texts and the empty text, since it prints nothing when the variable is unset (`${X:+x}$HOME` is `$HOME` too); - a substring is the variable and the `/` a path begins with (`${PWD:0:1}`), and a replacement whose pattern is only `*` is the variable and its string (`${X/*/$HOME}`); - an error message (`${DIR:?$HOME}`) stays the variable alone. A word is every combination of its sites' texts. The spelling follows a word into nested expansions 8 deep (spellWordDepth, was 3) and holds at most 16 texts (maxSpellings); past either bound the site is spellCapped, which the arg_values compare reads as naming every value (writtenMatches), so the bound refuses rather than passes. A string handed to a shell is written out once per text (spelledViews, at most 16 views) and every pairing is kept (spellPayload), so `sh -c "rm -rf ${DIR:-$HOME}"` blocks as the bare line does. The spelling's work is tallied and held linear. The adversarial corpus gains the refused forms and their safe look-alikes (`${DIR:-./build}`, `"${TMPDIR:-/tmp}/x"`, `${DIR:?}`, `${DIR/#\~/$HOME}`). The record gains its remedy line. Refs: iss-2609290426544292 Assisted-by: Claude:claude-opus-5-5 --- ...ads-a-default-expansion-by-its-variable.md | 1 + internal/core/guard/defaultword_test.go | 207 ++++++++++ internal/core/guard/homeresiduals_test.go | 7 +- internal/core/guard/match.go | 10 +- internal/core/guard/payload.go | 163 +++++--- .../guard/testdata/corpus/adversarial.txt | 35 ++ internal/core/guard/tokenize.go | 38 +- internal/core/guard/unknown.go | 376 ++++++++++++------ internal/core/guard/unknownsites_test.go | 2 +- internal/termsafe/codespan_canonical_test.go | 2 +- 10 files changed, 620 insertions(+), 221 deletions(-) create mode 100644 internal/core/guard/defaultword_test.go diff --git a/.abcd/work/issues/open/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md b/.abcd/work/issues/open/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md index dfdf244c8..b3db44fb6 100644 --- a/.abcd/work/issues/open/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md +++ b/.abcd/work/issues/open/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md @@ -9,6 +9,7 @@ found_during: "autonomous run 2026-09-23" origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/unknown.go" +remedy: "Make segment.spelled a set of the texts a word can print (varSite.texts, spellWritten), read a default's and an alternative's word through it (spellWord, the alternative also printing nothing), a substring's leading /, and a * replacement's string, pair every text through spellPayload, and bound depth and size so past either the word refuses, test first." deferred_after: v0.11.1 deferral_reason: "Reading a default's word needs a written spelling that holds more than one text (the variable's value or the default's word), which changes segment.spelled from one string per word to a set and the payload pairing that copies it (spellPayload); owed: that representation, then the default word, deep alternatives and a substring's root read through it, test first." --- diff --git a/internal/core/guard/defaultword_test.go b/internal/core/guard/defaultword_test.go new file mode 100644 index 000000000..2a5574c10 --- /dev/null +++ b/internal/core/guard/defaultword_test.go @@ -0,0 +1,207 @@ +package guard + +import ( + "strings" + "testing" +) + +// spellingCase is one line of the written-spelling tables below: the command, +// the shells it is also read through (a bare line, `bash -c '…'`, `sh -c +// "…"`), and the verdict and entry it must get. +type spellingCase struct { + cmd string + shells int + want Verdict + entry string +} + +const ( + shellBare = 1 << iota + shellSQ + shellDQ +) + +// checkSpellingCases runs each case as a bare line and as the string of each +// shell it names, and holds it to its verdict: a block names its entry at +// every depth, a warn names it on the bare line (a string holding `${` is a +// warn the payload reader may raise itself), and an allow is no rm-target +// verdict and no block anywhere, and an allow on the bare line. +func checkSpellingCases(t *testing.T, cases []spellingCase) { + t.Helper() + const home, cwd = "rm-rf-root-or-home", "rm-rf-working-directory" + for _, tc := range cases { + var spellings []string + if tc.shells&shellBare != 0 { + spellings = append(spellings, tc.cmd) + } + if tc.shells&shellSQ != 0 { + spellings = append(spellings, `bash -c '`+tc.cmd+`'`) + } + if tc.shells&shellDQ != 0 { + spellings = append(spellings, `sh -c "`+strings.ReplaceAll(tc.cmd, `"`, `\"`)+`"`) + } + for n, cmd := range spellings { + t.Run(cmd, func(t *testing.T) { + d := verdictOf(t, cmd) + switch tc.want { + case VerdictBlock: + if d.Verdict != VerdictBlock || d.EntryID != tc.entry { + t.Errorf("Check(%q) = %q via %q, want block via %q", cmd, d.Verdict, d.EntryID, tc.entry) + } + case VerdictWarn: + if d.Verdict != VerdictWarn || (n == 0 && d.EntryID != tc.entry) { + t.Errorf("Check(%q) = %q via %q, want warn via %q", cmd, d.Verdict, d.EntryID, tc.entry) + } + default: + if d.EntryID == home || d.EntryID == cwd || d.Verdict == VerdictBlock || (n == 0 && d.Verdict != VerdictAllow) { + t.Errorf("Check(%q) = %q via %q, want no rm-target verdict", cmd, d.Verdict, d.EntryID) + } + } + }) + } + } +} + +// TestDefaultWordsTheWrittenCompareReads — iss-2609290426544292. A default +// or an assignment (`${DIR:-w}`, `${DIR-w}`, `${DIR:=w}`, `${DIR=w}`) prints +// the variable's value when it is set and its word w when it is not, so its +// written spelling is both texts, and rm-rf-root-or-home reads the word as it +// reads the plain spelling: `rm -rf ${DIR:-$HOME}` deletes the home with DIR +// unset. bash 3.2 and /bin/sh, the shells of macOS, read the same default at +// the first operator after a subscript's `]` (`${X[0]]-$HOME}`), which bash 5 +// refuses as a bad substitution. +func TestDefaultWordsTheWrittenCompareReads(t *testing.T) { + const home, cwd = "rm-rf-root-or-home", "rm-rf-working-directory" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + // The home as a default's or an assignment's word. + {`rm -rf ${DIR:-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${DIR-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:=$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${DIR=$HOME}`, all, VerdictBlock, home}, + {`rm -rf "${DIR:-$HOME}"`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${DIR:-${HOME}}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-"$HOME"}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${DIR:-$HOME/}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-$HOME}/*`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-~}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-~/}`, all, VerdictBlock, home}, + {`rm -rf ${A:-${B:-$HOME}}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-${HOME%/}}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-x $HOME}`, all, VerdictBlock, home}, + {`rm -rf ${A:-x}${B:-$HOME}`, all, VerdictAllow, ""}, + {`rm -rf ${A:+x}${B:-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${A:+x}$HOME`, all, VerdictBlock, home}, + {`rm -rf ${A+/tmp}/`, all, VerdictBlock, home}, + // The root as the word. + {`rm -rf ${DIR:-/}`, all, VerdictBlock, home}, + {`rm -rf ${DIR-/}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:=/}`, all, VerdictBlock, home}, + {`rm -rf ${DIR=/*}`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-/}*`, all, VerdictBlock, home}, + {`rm -rf ${DIR:-'/'}`, shellBare | shellDQ, VerdictBlock, home}, + // A default after a subscript, at the first operator byte past its `]`. + {`rm -rf ${X[0]]-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${X[0]]:-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${X[0]]=$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${X[0]]:=$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${X[0]]x-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${X[0]]-/}`, all, VerdictBlock, home}, + // The working directory as the word warns, as its plain spelling does. + {`rm -rf ${DIR:-$PWD}`, shellBare, VerdictWarn, cwd}, + {`rm -rf ${DIR:-.}`, shellBare, VerdictWarn, cwd}, + // A word that names neither: the everyday default. + {`rm -rf ${DIR:-./build}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${DIR:-./build}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${DIR:-/tmp/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${DIR:-$HOME/.cache/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${TMPDIR:-/tmp}/abcd-x"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${DIR:-$HOME}x`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${DIR:-$HOME}/build"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${DIR:-}`, shellBare | shellSQ, VerdictAllow, ""}, + // An error message is not printed to the command's words. + {`rm -rf ${DIR:?$HOME}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${DIR?/}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X[0]]?$HOME}`, shellBare | shellSQ, VerdictAllow, ""}, + // A trimmed default is the variable's, not the word's. + {`rm -rf ${DIR%$HOME}`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestDeepAlternativesAndSubstringsTheWrittenCompareReads — +// iss-2609290426544292. An alternative nested deeper than the spelling used to +// follow (`${X:+${X:+${X:+${X:+$HOME}}}}`) prints its innermost word as a +// shallow one does, and a substring can print the root: `${PWD:0:1}` is the +// `/` every absolute path begins with. A replacement whose pattern is only +// `*` prints its string in place of the whole value (`${X/*/$HOME}`). +func TestDeepAlternativesAndSubstringsTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf ${X:+${X:+${X:+${X:+$HOME}}}}`, all, VerdictBlock, home}, + {`rm -rf ${X:+${X:+${X:+${X:+${X:+/}}}}}`, all, VerdictBlock, home}, + {`rm -rf ${A:-${B:-${C:-${D:-$HOME}}}}`, all, VerdictBlock, home}, + {`rm -rf ${PWD:0:1}`, all, VerdictBlock, home}, + {`rm -rf ${HOME:0:1}`, all, VerdictBlock, home}, + {`rm -rf ${PWD:0:1}*`, all, VerdictBlock, home}, + {`rm -rf ${X/*/$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${X//*/~}`, all, VerdictBlock, home}, + {`rm -rf ${X/#*/\/}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X:+${X:+${X:+${X:+./build}}}}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X/foo/$HOME}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${DIR/#\~/$HOME}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X/*/./build}`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestSpellingPastItsBoundRefuses — iss-2609290426544292. A written spelling +// is followed a bounded depth into nested expansions and holds a bounded +// number of texts. Past either bound the word is read as naming every value +// an arg_values entry names, so `rm -r` of it refuses (fail closed), where +// before the bound it was read as naming nothing; a command no arg_values +// entry names reads it as it always did. +func TestSpellingPastItsBoundRefuses(t *testing.T) { + const home = "rm-rf-root-or-home" + // Sixteen alternatives deep, past the depth the spelling follows. + deep := strings.Repeat("${X:+", 16) + "./build" + strings.Repeat("}", 16) + wide := "" + for i := 0; i < 6; i++ { + wide += "${V" + string(rune('A'+i)) + ":-x}" + } + checkSpellingCases(t, []spellingCase{ + {"rm -rf " + deep, shellBare | shellSQ | shellDQ, VerdictBlock, home}, + {"rm -rf " + wide, shellBare | shellSQ | shellDQ, VerdictBlock, home}, + {"echo " + deep, shellBare | shellSQ, VerdictAllow, ""}, + {"rm -f " + deep, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestSpellingSetsStayLinear holds the spelling sets to the cost bar +// (iss-2609290426544292): a nest of defaults is followed a bounded depth, a +// word of many defaults stops at the bound on its texts rather than +// enumerating every combination, and a string handed to a shell is re-read a +// bounded number of times. +func TestSpellingSetsStayLinear(t *testing.T) { + shapes := []struct { + name string + build func(int) string + }{ + {"nested defaults", func(n int) string { + return "rm -rf " + strings.Repeat("${X:-", n/5) + "$HOME" + strings.Repeat("}", n/5) + }}, + {"wide defaults", func(n int) string { + return "rm -rf " + strings.Repeat("${X:-$HOME}", n/11) + }}, + {"default words", func(n int) string { + return "rm -rf " + strings.Repeat(`${A:-"$HOME"/${B:-~/${C:-\x$D}}} `, n/32) + }}, + {"payload defaults", func(n int) string { + return `sh -c "rm -rf ` + strings.Repeat("${A:-/}${B:-~} x ", n/18) + `"` + }}, + } + for _, s := range shapes { + t.Run(s.name, func(t *testing.T) { + assertWorkGrowth(t, s.build, 1<<11, "a spelling set is bounded in its depth and its size") + }) + } +} diff --git a/internal/core/guard/homeresiduals_test.go b/internal/core/guard/homeresiduals_test.go index 8adbfb8b8..009e64f58 100644 --- a/internal/core/guard/homeresiduals_test.go +++ b/internal/core/guard/homeresiduals_test.go @@ -142,11 +142,6 @@ func TestHomeSpellingsTheWrittenCompareReads(t *testing.T) { {`rm -rf ${X:+/}x`, bare | sq, VerdictAllow, ""}, {`rm -rf $HOME{1..2}`, bare | sq, VerdictAllow, ""}, {`rm -rf "$HO"{M..M}E`, bare | sq, VerdictAllow, ""}, - // With X set this prints X's value; with X unset bash 3.2 and - // /bin/sh print the word, the home. The default's word is the - // deferred class of iss-2609290426544292, so this allow is a known - // residual, not a claim that the form stays off the home. - {`rm -rf ${X[0]]-$HOME}`, bare | sq, VerdictAllow, ""}, {`rm -rf "${X:+$HOME }"`, bare | sq, VerdictAllow, ""}, {`rm -rf ${X:+"$HOME "}`, bare | sq, VerdictAllow, ""}, {`rm -rf ${X:+'x /'}`, bare, VerdictAllow, ""}, @@ -190,7 +185,7 @@ func TestHomeSpellingsTheWrittenCompareReads(t *testing.T) { // TestHomeSpellingsStayLinear holds the spellings above to the cost bar // (iss-2609290419119456): an alternative's word is followed at most -// spellAlternativeDepth deep, a brace group's words carry their variables by +// spellWordDepth deep, a brace group's words carry their variables by // index, and a name read across backslash-newlines is read once. func TestHomeSpellingsStayLinear(t *testing.T) { shapes := []struct { diff --git a/internal/core/guard/match.go b/internal/core/guard/match.go index db0e5209c..205fdc3af 100644 --- a/internal/core/guard/match.go +++ b/internal/core/guard/match.go @@ -557,8 +557,8 @@ type entryMatcher struct { // a value (`git -$(x) /tmp push`); a word that may print nothing both is and is // not an operand (`git $(true) push`). The subcommands, the count, the prefix // and the path are all met by one reading. spelled is the segment's -// segment.spelled, which only the arg_values clause reads (writtenOperand). -func newEntryMatcher(p Pattern, tokens []string, spelled map[int]string, glob func(int) bool) entryMatcher { +// segment.spelled, which only the arg_values clause reads (writtenMatches). +func newEntryMatcher(p Pattern, tokens []string, spelled map[int][]string, glob func(int) bool) entryMatcher { n := len(tokens) want := operandWant{ sub: p.Subcommand, sub2: p.Subcommand2, min: p.MinOperands, @@ -699,9 +699,9 @@ func argPrefixMatches(prefix string, ops []string) bool { return false } -// argValueMatches reports whether an operand, as writtenOperand reads it, is -// one of the words. Only operands are considered, and a substitution's output -// is taken as empty, as argPrefixMatches reads a prefix: a word that is wholly +// argValueMatches reports whether an operand, as writtenMatches reads each +// text of its written spelling, is one of the words. Only operands are +// considered, and a substitution's output is taken as empty, as argPrefixMatches reads a prefix: a word that is wholly // a substitution is how an everyday delete names its target (`rm -rf // "$(mktemp -d)"`), so reading it as every target would refuse them all // (unknown.go's operand residual). A variable is compared as the line wrote diff --git a/internal/core/guard/payload.go b/internal/core/guard/payload.go index 8fc8b2bf2..40c7d7432 100644 --- a/internal/core/guard/payload.go +++ b/internal/core/guard/payload.go @@ -244,99 +244,136 @@ func payloadView(s segment) segment { return v } -// namedPayloads returns, parallel to refs, the text of each string as the -// line wrote its variables (segment.spelled), "" where it is no other text or -// cannot be paired: payloadView hands a string the mark of each value the +// namedPayloads returns, parallel to refs, the texts of each string as the +// line wrote its variables (segment.spelled), none where it is no other text +// or cannot be paired: payloadView hands a string the mark of each value the // enclosing shell put in it, which reads as an unknown word with no name, so -// `sh -c "rm -rf $HOME"` holds a mark where `$HOME` was written. Only the -// arg_values compare reads what spellPayload takes from it; every reading of -// the string reads the marks (iss-2609290321312087). The words are paired by -// payloadsOf's own order, and a pair whose kind or family differs is not -// paired. -func namedPayloads(s segment, refs []payloadRef) []string { - named := make([]string, len(refs)) - v, ok := spelledView(s) - if !ok { - return named - } - nrefs := payloadRefsOf(v) - if len(nrefs) != len(refs) { - return named - } - for r, ref := range refs { - if n := nrefs[r]; n.kind == ref.kind && n.family == ref.family && n.payload != ref.payload { - named[r] = n.payload +// `sh -c "rm -rf $HOME"` holds a mark where `$HOME` was written. A word's +// spelling is a set (`${DIR:-$HOME}` is `${DIR}` and `$HOME`), so the string +// is written out once for each text (spelledViews), and each reading is +// paired on its own. Only the arg_values compare reads what spellPayload +// takes from them; every reading of the string reads the marks +// (iss-2609290321312087). The words are paired by payloadsOf's own order, and +// a pair whose kind or family differs is not paired. +func namedPayloads(s segment, refs []payloadRef) [][]string { + if len(refs) == 0 { + return nil + } + named := make([][]string, len(refs)) + for _, v := range spelledViews(s) { + nrefs := payloadRefsOf(v) + if len(nrefs) != len(refs) { + continue + } + for r, ref := range refs { + if n := nrefs[r]; n.kind == ref.kind && n.family == ref.family && n.payload != ref.payload { + named[r] = appendText(named[r], n.payload) + } } } return named } -// spelledView is payloadView with each word the line wrote with a known +// spelledViews is payloadView with each word the line wrote with a known // variable spelled as the line wrote it (segment.spelled) instead of with // varMark, where payloadView spells it; a variable whose text is not known -// stays varMark. ok is false when no word changes. -func spelledView(s segment) (segment, bool) { +// stays varMark. A word's spelling is a set, so there is one view for each +// place in the largest set, and view k writes each word's k-th text (its last +// where it has fewer): every text of every word is written in some view, and +// the views number at most maxSpellings. nil when no word changes. +func spelledViews(s segment) []segment { if len(s.spelled) == 0 { - return s, false + return nil } v := payloadView(s) - var toks []string + written := map[int][]string{} + most := 0 for i, text := range s.variable { // payloadView spelled this word with varMark (text); a word it left, // where a command can sit, keeps its unknownMark and is left here too. - w, ok := s.spelled[i] + ws, ok := s.spelled[i] if !ok || v.tokens[i] != text { continue } - w = strings.ReplaceAll(strings.ReplaceAll(w, fieldText, " "), quotedFieldText, fieldText) - if w = strings.ReplaceAll(w, unknownText, varText); w == text { - continue + var texts []string + changed := false + for _, w := range ws { + w = strings.ReplaceAll(strings.ReplaceAll(w, fieldText, " "), quotedFieldText, fieldText) + w = strings.ReplaceAll(w, unknownText, varText) + changed = changed || w != text + texts = append(texts, w) } - if toks == nil { - toks = append([]string(nil), v.tokens...) + if !changed { + continue } - toks[i] = w + written[i] = texts + most = max(most, len(texts)) } - if toks == nil { - return v, false + if most == 0 { + return nil } - v.tokens = toks - return v, true + views := make([]segment, most) + for k := range views { + toks := append([]string(nil), v.tokens...) + for i, texts := range written { + toks[i] = texts[min(k, len(texts)-1)] + } + views[k] = v + views[k].tokens = toks + } + return views } -// spellPayload reads the string named, the same string as the one psegs +// spellPayload reads each string named, the same string as the one psegs // were read from with its variables written out (namedPayloads), and gives -// each word of psegs that holds a variable the spelling of the word at the -// same place of named: its own segment.spelled, or its text where the string -// quotes the name (`sh -c "rm -rf '$HOME'"`). A word is paired only when -// both readings have the same segments and words, and the word read from -// named has the mark-view word's known text in the same order around it -// (fitsWritten); an unpaired word keeps the spelling it has, which names no -// variable. Nothing else of psegs is changed. -func spellPayload(psegs []segment, named string) { - if named == "" { - return - } - nsegs, err := tokenize(named) - if err != nil || len(nsegs) != len(psegs) { - return - } - for i := range psegs { - m, n := psegs[i], nsegs[i] - if len(m.spelled) == 0 || len(m.tokens) != len(n.tokens) { +// each word of psegs that holds a variable the texts of the word at the same +// place of each: its own segment.spelled, or its text where the string quotes +// the name (`sh -c "rm -rf '$HOME'"`). A word is paired only when both +// readings have the same segments and words, and the word read from named has +// the mark-view word's known text in the same order around it (fitsWritten); +// a word no reading pairs keeps the spelling it has, which names no variable. +// A word paired with more than maxSpellings texts, or with a text past a +// bound, is spellCapped. Nothing else of psegs is changed. +func spellPayload(psegs []segment, named []string) { + paired := make([]map[int][]string, len(psegs)) + for _, nm := range named { + nsegs, err := tokenize(nm) + if err != nil || len(nsegs) != len(psegs) { continue } - for j := range m.spelled { - w, ok := n.spelled[j] - if !ok { - if isUnknown(n.tokens[j]) { + for i := range psegs { + m, n := psegs[i], nsegs[i] + if len(m.spelled) == 0 || len(m.tokens) != len(n.tokens) { + continue + } + for j := range m.spelled { + ws, ok := n.spelled[j] + if !ok { + if isUnknown(n.tokens[j]) { + continue + } + ws = []string{n.tokens[j]} + } + if !fitsWritten(m.tokens[j], n.tokens[j]) { continue } - w = n.tokens[j] + for _, w := range ws { + if fitsWritten(m.tokens[j], w) { + if paired[i] == nil { + paired[i] = map[int][]string{} + } + paired[i][j] = appendText(paired[i][j], w) + } + } } - if fitsWritten(m.tokens[j], n.tokens[j]) && fitsWritten(m.tokens[j], w) { - m.spelled[j] = w + } + } + for i, words := range paired { + for j, texts := range words { + if capped(texts) || len(texts) > maxSpellings { + texts = []string{spellCapped} } + psegs[i].spelled[j] = texts } } } diff --git a/internal/core/guard/testdata/corpus/adversarial.txt b/internal/core/guard/testdata/corpus/adversarial.txt index c2cffc235..4d64d7e51 100644 --- a/internal/core/guard/testdata/corpus/adversarial.txt +++ b/internal/core/guard/testdata/corpus/adversarial.txt @@ -139,6 +139,41 @@ quiet flock /tmp/lock /bin/echo -c "git push --force origin main" quiet flock -w 5 /tmp/lock /bin/echo --command "git push --force origin main" warn flock /tmp/lock kubectl exec -c app pod -- gh repo delete owner/repo +# --- block: a parameter expansion that can print the root or the home ------ +# iss-2609290426544292. A default prints its word when the variable is unset, +# an alternative prints its word or nothing, a substring can print the `/` a +# path begins with, and bash 3.2 reads a default after a subscript's `]`. A +# spelling nested past the depth the guard follows refuses rather than passes. +block rm -rf ${DIR:-$HOME} +block rm -rf "${DIR:-$HOME}" +block rm -rf ${DIR:-/} +block rm -rf ${DIR-$HOME}/* +block rm -rf ${DIR:=/} +block rm -rf ${DIR:-~} +block rm -rf ${X[0]]-$HOME} +block rm -rf ${X[0]]:-/} +block rm -rf ${X:+${X:+${X:+${X:+$HOME}}}} +block rm -rf ${PREFIX:+$PREFIX}/ +block rm -rf ${PWD:0:1} +block rm -rf ${X/*/$HOME} +block sh -c "rm -rf ${DIR:-$HOME}" +block bash -c 'rm -rf ${DIR:-/}' +block rm -rf ${X:+${X:+${X:+${X:+${X:+${X:+${X:+${X:+${X:+./build}}}}}}}}} + +# --- quiet: the same expansions naming a safe path --------------------------- +quiet rm -rf ${DIR:-./build} +quiet rm -rf "${BUILD_DIR:-./build}" +quiet rm -rf "${TMPDIR:-/tmp}/abcd-x" +quiet rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/abcd" +quiet rm -rf ${DIR:-$HOME}/build +quiet rm -rf ${DIR:?} +quiet rm -rf ${DIR:?set DIR} +quiet rm -rf "${OUT%/}" +quiet rm -rf "${DIR/#\~/$HOME}" +quiet rm -rf ${X:+${X:+${X:+${X:+./build}}}} +quiet sh -c "rm -rf ${DIR:-./build}" +quiet echo ${X:+${X:+${X:+${X:+${X:+${X:+${X:+${X:+${X:+x}}}}}}}}} + # --- fp: accepted false positives, and the exact shape they take ------------ fp rg git push --force docs/ fp grep -rn gh repo delete . diff --git a/internal/core/guard/tokenize.go b/internal/core/guard/tokenize.go index 392a7c296..35735d2e8 100644 --- a/internal/core/guard/tokenize.go +++ b/internal/core/guard/tokenize.go @@ -82,15 +82,16 @@ type segment struct { // for the re-read to take as a variable's (payloadView). variable map[int]string // spelled records, per token index, a word holding a parameter - // expansion's mark as the line WROTE it: each variable's mark replaced by - // its expansion's text (`$HOME`, `${PWD}`; a simple name the next byte - // would extend is braced), every substitution's mark dropped, and a mark - // whose text is not known — a varMark carried into a payload's text — - // kept as unknownMark. Only an entry's arg_values read it - // (writtenOperand): every other reading takes the token, where the - // variable is the unknown word's mark (iss-2609290321312087). nil when no - // word holds a variable. - spelled map[int]string + // expansion's mark as the line WROTE it, as the set of texts it can + // print (spellWritten): each variable's mark replaced by each text its + // expansion can print (`$HOME`, `${PWD}`; `${DIR}` and `$HOME` for + // `${DIR:-$HOME}`; a simple name the next byte would extend is braced), + // every substitution's mark dropped, and a mark whose text is not known — + // a varMark carried into a payload's text — kept as unknownMark. Only an + // entry's arg_values read it (writtenMatches): every other reading takes + // the token, where the variable is the unknown word's mark + // (iss-2609290321312087). nil when no word holds a variable. + spelled map[int][]string // arrivals caches commandArrivals(tokens) once Check has its final // segments (walked records that it is set), so the walk to command position // is paid once per segment rather than once per entry. A segment built @@ -386,7 +387,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // spells rides with the segment (segment.spelled); curVarAt records, // for the word being built, where in cur each variable's mark stands // and the expansion's text, "" where it is not known. - spells map[int]string + spells map[int][]string curVarAt []varSite // curMask is parallel to cur and records, per byte, whether it reached // the tokenizer unquoted (wordStruct) and whether it began its word @@ -615,10 +616,11 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { vars[len(toks)] = text } // addVar leaves the mark of a parameter expansion where its value goes, - // and records the expansion's text as the line wrote it (segment.spelled). - addVar := func(text string) { + // and records the texts it can print as the line wrote them + // (segment.spelled). + addVar := func(texts ...string) { addCur([]byte{varMark}, 0) - curVarAt[len(curVarAt)-1].text = text + curVarAt[len(curVarAt)-1].texts = texts } // recordSpelling files the word being built under segment.spelled when a // variable's mark is in it: word is the token it becomes, and whole @@ -631,13 +633,13 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { return } if spells == nil { - spells = map[int]string{} + spells = map[int][]string{} } switch { case whole: spells[len(toks)] = spellWritten(cur, curVarAt, nil) case isUnknown(word): - spells[len(toks)] = unknownText + spells[len(toks)] = []string{unknownText} } } // recordBraceSpelling is recordSpelling for one word a brace group made: @@ -661,7 +663,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { return } if spells == nil { - spells = map[int]string{} + spells = map[int][]string{} } spells[len(toks)] = spellWritten(w.b, sites, w.m) } @@ -1025,7 +1027,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { start := len(segs) expandedBody(body) feedFrom(start) - addVar(spellParameter(body, split)) + addVar(spellParameter(body, split)...) if len(segs) > start { curSub = true } @@ -2169,7 +2171,7 @@ type enclosing struct { vars map[int]string curVar bool curSub bool - spells map[int]string + spells map[int][]string curVarAt []varSite cur []byte curMask []byte diff --git a/internal/core/guard/unknown.go b/internal/core/guard/unknown.go index df0c99c39..4ab7ddf79 100644 --- a/internal/core/guard/unknown.go +++ b/internal/core/guard/unknown.go @@ -103,63 +103,134 @@ const varMark = '\x01' const varText = "\x01" // varSite is one variable's mark in a word being built: its offset in the -// word, and the expansion's text as the line wrote it (`$HOME`, `${PWD}`), "" +// word, and the texts the expansion can print as the line wrote them (`$HOME`, +// `${PWD}`; `${DIR}` and `$HOME` for `${DIR:-$HOME}`, spellParameter), nil // for a varMark read from a payload's text, whose name the string no longer // holds. bare records a name written unquoted and without braces, which the // unquoted text a brace group places after it runs on from (spellWritten). type varSite struct { - at int - text string - bare bool + at int + texts []string + bare bool } +// A written spelling (segment.spelled) is a SET of texts, one for each thing +// the word can print as the line wrote it: `${DIR:-$HOME}` prints DIR's value +// or the home, and is `${DIR}` and `$HOME` (iss-2609290426544292). A word's +// set is every combination of its sites' sets, bounded by maxSpellings, and a +// site's word is followed into its own expansions at most spellWordDepth +// deep. Past either bound the site is spellCapped: a spelling the guard +// stopped reading, which the arg_values compare reads as every value an entry +// names (writtenMatches), so a bound refuses rather than lets the word pass. +const ( + // maxSpellings bounds how many texts one word's written spelling holds, + // and so how many times a string handed to a shell is re-read to pair + // them (namedPayloads). + maxSpellings = 16 + // spellWordDepth bounds how deep a spelling follows a default's or an + // alternative's word into another expansion. + spellWordDepth = 8 + // spellCapped is the one text of a spelling past either bound. + spellCapped = "\x04" +) + // spellWritten is a word as the line wrote its variables (segment.spelled): -// each variable's mark replaced by its expansion's text, each substitution's -// mark dropped as knownText drops it, and a variable whose text is not known -// kept as unknownMark. A simple name the next byte kept would extend is -// braced (`"$A"B` is `${A}B`, not `$AB`), so the spelling reads as the same -// expansions when it is read again (spelledView). sites is in word order. +// each variable's mark replaced by each text its expansion can print, each +// substitution's mark dropped as knownText drops it, and a variable whose +// text is not known kept as unknownMark. The result is every combination of +// the sites' texts, in word order; a site already past a bound, or one that +// would make more than maxSpellings of them, is spellCapped in each. A simple name the next byte +// kept would extend is braced (`"$A"B` is `${A}B`, not `$AB`), so the +// spelling reads as the same expansions when it is read again (spelledViews). +// sites is in word order. // // mask is nil for a word as the line wrote it. For a word a brace group made // it is the word's bword.m, and a bare name directly followed by unquoted // name bytes is not braced: bash expands the group first and reads the name // after, so `$HO{ME,}` makes `$HOME` (iss-2609290419119456). A quote or // escape between them leaves the byte quoted, and the name ends there. -func spellWritten(word []byte, sites []varSite, mask []byte) string { - var b strings.Builder +func spellWritten(word []byte, sites []varSite, mask []byte) []string { + outs := [][]byte{nil} + add := func(c byte) { + for i := range outs { + outs[i] = append(outs[i], c) + } + } k := 0 isVar := func(p int) bool { return k < len(sites) && sites[k].at == p } for p := 0; p < len(word); p++ { if !isVar(p) { if word[p] != unknownMark { - b.WriteByte(word[p]) + add(word[p]) } continue } site := sites[k] - text := site.text k++ - if text == "" { - b.WriteByte(unknownMark) + if len(site.texts) == 0 { + add(unknownMark) continue } - // Only a simple name is braced: an alternative's word is spelled as - // written (`$HOME/`, `~`), and bracing that would change it. - if len(text) > 1 && text[0] == '$' && simpleParamEnd(text, 1) == len(text) { - next := p + 1 - for next < len(word) && word[next] == unknownMark && !isVar(next) { - next++ - } - if next < len(word) && word[next] != unknownMark && isNameByte(word[next]) { - runsOn := mask != nil && site.bare && next == p+1 && mask[next]&wordStruct != 0 - if !runsOn { + if capped(site.texts) || len(outs)*len(site.texts) > maxSpellings { + // The site is past a bound: it is spellCapped in every text, and + // the text around it is kept, so a string handed to a shell still + // pairs its words (spellPayload). + add(spellCapped[0]) + continue + } + // Only a simple name is braced: a default's or an alternative's word + // is spelled as written (`$HOME/`, `~`), and bracing that would + // change it. + braced := false + next := p + 1 + for next < len(word) && word[next] == unknownMark && !isVar(next) { + next++ + } + if next < len(word) && word[next] != unknownMark && isNameByte(word[next]) { + runsOn := mask != nil && site.bare && next == p+1 && mask[next]&wordStruct != 0 + braced = !runsOn + } + grown := make([][]byte, 0, len(outs)*len(site.texts)) + for _, o := range outs { + for n, text := range site.texts { + if braced && len(text) > 1 && text[0] == '$' && simpleParamEnd(text, 1) == len(text) { text = "${" + text[1:] + "}" } + b := o + if n < len(site.texts)-1 { + b = append([]byte(nil), o...) + } + grown = append(grown, append(b, text...)) } } - b.WriteString(text) + outs = grown } - return b.String() + texts := make([]string, 0, len(outs)) + for _, o := range outs { + tally(len(o)) + texts = appendText(texts, string(o)) + } + return texts +} + +// appendText adds text to a spelling's texts unless it is already there. +func appendText(texts []string, text string) []string { + for _, t := range texts { + if t == text { + return texts + } + } + return append(texts, text) +} + +// capped reports whether a spelling is past a bound (spellCapped). +func capped(texts []string) bool { + for _, t := range texts { + if strings.Contains(t, spellCapped) { + return true + } + } + return false } // paramText is a parameter expansion's text as bash reads it: without the @@ -168,44 +239,53 @@ func spellWritten(word []byte, sites []varSite, mask []byte) string { func paramText(text string) string { return strings.ReplaceAll(text, "\\\n", "") } // spellParameter is the written spelling (segment.spelled) of a `${…}` -// expansion whose text between the braces is body. Where the expansion can -// print its variable's value unchanged, it is spelled as that variable, so -// arg_values reads `${HOME%/}` as the `${HOME}` it can be -// (iss-2609290419119456): +// expansion whose text between the braces is body: every text it can print +// as the line wrote it. Where the expansion can print its variable's value +// unchanged, the set holds that variable, so arg_values reads `${HOME%/}` as +// the `${HOME}` it can be (iss-2609290419119456): // -// - a default, an assignment or an error message, with or without the colon -// (`${HOME:-x}`, `${HOME=x}`, `${HOME:?x}`): the value when the variable -// is set, and the home always is; +// - a default or an assignment, with or without the colon (`${DIR:-w}`, +// `${DIR=w}`), prints the value when the variable is set and its word w +// when it is not, so the set holds the variable and every text w can +// print, through its own expansions (spellWord): `${DIR:-$HOME}` is +// `${DIR}` and `$HOME` (iss-2609290426544292); +// - an error message (`${HOME:?x}`) prints the value or nothing: the +// message goes to the standard error, never into the word; // - a trimmed prefix or suffix and a pattern replacement (`${HOME%/}`, // `${HOME#x}`, `${HOME/x/y}`): the value when the pattern does not match, -// and what a suffix trim leaves otherwise is the path above it; -// - a substring (`${HOME:0}`), whose offset is arithmetic and can be 0; +// and what a suffix trim leaves otherwise is the path above it. A +// replacement whose pattern is only `*` replaces the whole value, so it +// also prints its string (`${X/*/$HOME}` is `${X}` and `$HOME`); +// - a substring (`${HOME:0}`), whose offset is arithmetic and can be 0, +// and which can print the `/` an absolute path begins with +// (`${PWD:0:1}` is `${PWD}` and `/`); // - a case change (`${HOME^^}`, `${HOME@U}`), which names the same directory // on a case-insensitive disk, and `@E` and `@P`, which change no path; // - a subscript (`${HOME[0]}`, `${HOME[x[0]]}`), which can be 0, read to -// its matching `]`, with anything after it but an alternative at the -// first operator byte: bash 3.2 prints the value past any other text -// (`${HOME[0]]}`, `${HOME[0]@Q}`), and a subscript with no `]` cannot be -// read further. +// its matching `]`. bash 3.2, the /bin/sh and /bin/bash of macOS, steps +// over any text after it to the first operator byte +// (subscriptOperators): an alternative there prints its word, a default +// or an assignment the value or its word (`${X[0]]-$HOME}` is the home +// with X unset), and anything else the value (`${HOME[0]]}`, +// `${HOME[0]@Q}`). A subscript with no `]` cannot be read further. // -// An alternative (`${X:+w}`, `${X+w}`) prints w or nothing, and is -// spelled as w is written, through its own expansions (spellAlternative). -// Every other expansion keeps its text as written and names no variable an -// entry names: a length (`${#HOME}`), an indirection (`${!X}`), `@Q` and the -// other transforms, and a default word that is not the variable's own value -// (`${DIR:-$HOME}`), which is a recorded residual (17-guard.md). +// An alternative (`${X:+w}`, `${X+w}`) prints w or nothing, and is the texts +// w can print and the empty text. Every other expansion keeps its text as written and names no +// variable an entry names: a length (`${#HOME}`), an indirection (`${!X}`), +// and `@Q` and the other transforms. // -// split reports that the expansion stands unquoted, where bash splits an -// alternative's word on whitespace: each unquoted whitespace run in it is -// spelled fieldMark, which the compare splits on (argValueMatches). -func spellParameter(body string, split bool) string { +// split reports that the expansion stands unquoted, where bash splits a +// default's or an alternative's word on whitespace: each unquoted whitespace +// run in it is spelled fieldMark, which the compare splits on +// (argValueMatches). +func spellParameter(body string, split bool) []string { return spellParameterAt(paramText(body), 0, split) } // fieldMark stands in a spelling where bash splits a word into fields: at an // unquoted whitespace run in an unquoted alternative's word (`${X:+$HOME }` // hands rm the home). Only the arg_values compare splits on it; a payload -// re-read reads it as the space it was (spelledView). +// re-read reads it as the space it was (spelledViews). const fieldMark = '\x02' // fieldText is fieldMark as a string. @@ -215,18 +295,14 @@ const fieldText = "\x02" // unquoted whitespace (`sh -c "rm -rf ${X:+$HOME x}"`): the word is one // field here, which the compare reads as a space, but a shell re-reading the // string splits it there, so a payload re-read takes it for fieldMark -// (spelledView), and the string's words pair with its marked reading's. +// (spelledViews), and the string's words pair with its marked reading's. const quotedFieldMark = '\x03' // quotedFieldText is quotedFieldMark as a string. const quotedFieldText = "\x03" -// spellAlternativeDepth bounds how deep spellParameter follows an -// alternative's word into another expansion. -const spellAlternativeDepth = 3 - -func spellParameterAt(body string, depth int, split bool) string { - raw := "${" + body + "}" +func spellParameterAt(body string, depth int, split bool) []string { + raw := []string{"${" + body + "}"} n := 0 for n < len(body) && isNameByte(body[n]) { n++ @@ -236,62 +312,93 @@ func spellParameterAt(body string, depth int, split bool) string { } name, rest := body[:n], body[n:] same := "${" + name + "}" + value := []string{same} + // orWord is the value, or the texts the word w can print. + orWord := func(w string) []string { + texts := value + for _, t := range spellWord(w, depth, split) { + texts = appendText(texts, t) + } + if capped(texts) || len(texts) > maxSpellings { + return []string{spellCapped} + } + return texts + } + // alternative is the texts the word w can print, or nothing: with the + // variable unset or empty the expansion prints no text, so + // `${X:+x}$HOME` is `x$HOME` and `$HOME`. A word the guard cannot read + // is the expansion as written, which names nothing. + alternative := func(w string) []string { + texts := spellWord(w, depth, split) + if len(texts) == 0 { + texts = raw + } + return appendText(texts, "") + } + subscript := false if strings.HasPrefix(rest, "[") { - // The subscript runs to its matching `]`, and what follows it is read - // only for an alternative: bash 3.2, the /bin/sh and /bin/bash of - // macOS, steps over any other text to the first operator byte - // (subscriptOperators) and reads an alternative there - // (`${X[0]]:+$HOME}`, `${X[0]x:+$HOME}`, `${X[0]]^+$HOME}` print the - // home), and prints the value past text that holds none - // (`${HOME[0]]}`, `${HOME[0]@Q}`). A subscript that does not close - // can be read no further. Every other case is spelled as the variable. k := subscriptEnd(rest) if k < 0 { - return same + return value } rest = rest[k+1:] - if op := strings.IndexAny(rest, subscriptOperators); op >= 0 { - switch { - case rest[op] == '+': - return spellAlternative(rest[op+1:], raw, depth, split) - case strings.HasPrefix(rest[op:], ":+"): - return spellAlternative(rest[op+2:], raw, depth, split) - } + op := strings.IndexAny(rest, subscriptOperators) + if op < 0 { + return value } - return same + rest, subscript = rest[op:], true } if rest == "" { - return same + return value } - // valueKeeping is every operator that can print the value unchanged. - const valueKeeping = "-=?#%/^,~" - if strings.IndexByte(valueKeeping, rest[0]) >= 0 { - return same - } - switch rest[0] { - case '@': - if len(rest) == 2 && strings.IndexByte("EPULu", rest[1]) >= 0 { - return same + switch { + case rest[0] == '+': + return alternative(rest[1:]) + case strings.HasPrefix(rest, ":+"): + return alternative(rest[2:]) + case rest[0] == '-' || rest[0] == '=': + return orWord(rest[1:]) + case strings.HasPrefix(rest, ":-") || strings.HasPrefix(rest, ":="): + return orWord(rest[2:]) + case subscript, strings.HasPrefix(rest, ":?"): + return value + case rest[0] == ':': + // A substring: a part of the value, the whole of it at offset 0, and + // the `/` an absolute path begins with. + return []string{same, "/"} + case rest[0] == '/': + // A pattern replacement, `/`, `//`, `/#` or `/%`. A pattern of only + // `*` matches the whole value, which its string replaces. + p := rest[1:] + if p != "" && strings.IndexByte("/#%", p[0]) >= 0 { + p = p[1:] } - case '+': - return spellAlternative(rest[1:], raw, depth, split) - case ':': - if len(rest) > 1 && rest[1] == '+' { - return spellAlternative(rest[2:], raw, depth, split) + s := 0 + for s < len(p) && p[s] == '*' { + s++ + } + if s > 0 && s < len(p) && p[s] == '/' { + return orWord(p[s+1:]) + } + return value + case strings.IndexByte("?#%^,~", rest[0]) >= 0: + // Every other operator that can print the value unchanged. + return value + case rest[0] == '@': + if len(rest) == 2 && strings.IndexByte("EPULu", rest[1]) >= 0 { + return value } - return same } return raw } // subscriptOperators are the bytes bash 3.2 stops at in the text after a // subscript's `]`: an operator, or a backslash, which quotes the next byte. -// Only a `+` or `:+` there reads an alternative; with X set, -// `${X[0]]-$HOME}` and `${X[0]a-b+$HOME}` print X's value, and -// `${X[0]]\+$HOME}` does too. With X unset, a `-`, `:-`, `=` or `:=` there -// prints the word: `${X[0]]-$HOME}`, `${X[0]]:-$HOME}` and `${X[0]]=$HOME}` -// print the home on bash 3.2 and /bin/sh. That is the default's word, which -// this spelling does not read (iss-2609290426544292, deferred). +// A `+` or `:+` there reads an alternative, and a `-`, `:-`, `=` or `:=` a +// default or an assignment: with X unset, `${X[0]]-$HOME}`, +// `${X[0]]:-$HOME}` and `${X[0]]=$HOME}` print the home on bash 3.2 and +// /bin/sh, and with X set they print X's value. Any other byte there prints +// the value: `${X[0]]?$HOME}`, and `${X[0]]\+$HOME}`. const subscriptOperators = "-=?+%#/:\\" // subscriptEnd returns the index of the `]` that closes the subscript opening @@ -312,21 +419,23 @@ func subscriptEnd(s string) int { return -1 } -// spellAlternative is the spelling of an alternative whose word is w. An -// alternative prints w or nothing, so w is spelled as it is written: its -// quotes and escapes removed, and each expansion in it a site spelled as a -// word's own are (spellWritten), `${…}` through spellParameterAt. +// spellWord is the texts a default's or an alternative's word w can print, +// at depth expansions deep. w is spelled as it is written: its quotes and +// escapes removed, and each expansion in it a site spelled as a word's own +// are (spellWritten), `${…}` through spellParameterAt one level deeper. // `${X:+$HOME/}` is `$HOME/`, `${X:+/}` is `/` and `${X:+"${HOME%/}"}` is // `${HOME}`. A command substitution in it is its unknown output, which // spellWritten drops as knownText does (`${X:+$(true)$HOME}` is `$HOME`). // Where split is set, each unquoted whitespace run is fieldMark, where bash -// splits the word (`${X:+$HOME }` is `$HOME`). A word holding a quote that -// does not close or an expansion past spellAlternativeDepth, and a word that -// spells to nothing, keep raw. -func spellAlternative(w, raw string, depth int, split bool) string { - if depth >= spellAlternativeDepth { - return raw +// splits the word (`${X:+$HOME }` is `$HOME`). A word holding a quote or an +// expansion that does not close, and a word that spells to nothing, print no +// text the guard reads, and are nil. A word at spellWordDepth is not read, +// and is spellCapped: the bound refuses, never passes (writtenMatches). +func spellWord(w string, depth int, split bool) []string { + if depth >= spellWordDepth { + return []string{spellCapped} } + tally(len(w)) var word []byte var sites []varSite budget := 4*len(w) + 16 @@ -347,29 +456,29 @@ func spellAlternative(w, raw string, depth int, split bool) string { case c == '\'' && !dq: k := strings.IndexByte(w[i+1:], '\'') if k < 0 { - return raw + return nil } word = append(word, w[i+1:i+1+k]...) i += k + 2 case c == '$' && i+1 < len(w) && w[i+1] == '{': end := closingDolBrace(w, i+2, &budget) if end < 0 { - return raw + return nil } - sites = append(sites, varSite{at: len(word), text: spellParameterAt(w[i+2:end], depth+1, split && !dq)}) + sites = append(sites, varSite{at: len(word), texts: spellParameterAt(w[i+2:end], depth+1, split && !dq)}) word = append(word, varMark) i = end + 1 case c == '$' && i+1 < len(w) && w[i+1] == '(': end := closingParen(w, i+2, &budget) if end < 0 { - return raw + return nil } word = append(word, unknownMark) i = end + 1 case c == '`': end := closingBacktick(w, i+1, &budget) if end < 0 { - return raw + return nil } word = append(word, unknownMark) i = end + 1 @@ -385,9 +494,9 @@ func spellAlternative(w, raw string, depth int, split bool) string { case c == '$': end := simpleParamEnd(w, i+1) if end < 0 { - return raw + return nil } - sites = append(sites, varSite{at: len(word), text: w[i:end]}) + sites = append(sites, varSite{at: len(word), texts: []string{w[i:end]}}) word = append(word, varMark) i = end default: @@ -396,7 +505,7 @@ func spellAlternative(w, raw string, depth int, split bool) string { } } if dq || len(word) == 0 { - return raw + return nil } return spellWritten(word, sites, nil) } @@ -406,16 +515,29 @@ func isNameByte(c byte) bool { return c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' } -// writtenOperand is the text an entry's arg_values compare reads for the -// word at i: the word as the line wrote its variables where it holds one -// (segment.spelled), else its known text. It is read by nothing else, so -// `rm -rf $HOME` names `$HOME` to arg_values while every other reading takes -// the variable as the unknown word it is (iss-2609290321312087). -func writtenOperand(tokens []string, spelled map[int]string, i int) string { - if w, ok := spelled[i]; ok { - return w +// writtenMatches reports whether the word at i names one of an entry's +// arg_values as the line wrote it: some text of its written spelling +// (segment.spelled) where it holds a variable, else its known text +// (argValueMatches). It is read by nothing else, so `rm -rf $HOME` names +// `$HOME` to arg_values while every other reading takes the variable as the +// unknown word it is (iss-2609290321312087), and `rm -rf ${DIR:-/}` names the +// root as its default's word does (iss-2609290426544292). A spelling past its +// bound (spellCapped) names every value: the guard stopped reading it, and a +// bound refuses rather than passes. +func writtenMatches(values []string, tokens []string, spelled map[int][]string, i int) bool { + texts, ok := spelled[i] + if !ok { + return argValueMatches(values, knownText(tokens[i])) } - return knownText(tokens[i]) + if capped(texts) { + return true + } + for _, w := range texts { + if argValueMatches(values, w) { + return true + } + } + return false } // isUnknown reports whether a word carries a substitution's output. @@ -967,8 +1089,8 @@ type operandWant struct { // the table's state is (word, operands so far, clauses met), filled from the // end once: linear in the words, whatever the number of places a command can // sit. spelled is the segment's segment.spelled, read by the arg_values -// clause alone (writtenOperand). -func operandAcceptance(tokens []string, spelled map[int]string, valueFlags []string, want operandWant, glob func(int) bool) []bool { +// clause alone (writtenMatches). +func operandAcceptance(tokens []string, spelled map[int][]string, valueFlags []string, want operandWant, glob func(int) bool) []bool { need := want.need() nv := 0 if len(want.values) > 0 { @@ -1004,7 +1126,7 @@ func operandAcceptance(tokens []string, spelled map[int]string, valueFlags []str hits |= 1 << (len(want.prefixes) + j) } } - if nv > 0 && argValueMatches(want.values, writtenOperand(tokens, spelled, i)) { + if nv > 0 && writtenMatches(want.values, tokens, spelled, i) { hits |= 1 << (len(want.prefixes) + len(want.paths)) } } diff --git a/internal/core/guard/unknownsites_test.go b/internal/core/guard/unknownsites_test.go index 288c3bc0c..79b7dc42f 100644 --- a/internal/core/guard/unknownsites_test.go +++ b/internal/core/guard/unknownsites_test.go @@ -96,7 +96,7 @@ var wordReaders = map[string]string{ "keywordAt": "exempt: reserved words are grammar, which no substitution prints", "readHeredocDelim": "exempt: the `<<-` operator is grammar", "simpleParamEnd": "exempt: reads the `$-` special parameter's name, grammar that makes the word unknown", - "spellParameterAt": "exempt: reads a `${…}` expansion's `-` operator (`${HOME:-x}`), grammar that spells the variable for arg_values", + "spellParameterAt": "exempt: reads a `${…}` expansion's `-` operator (`${HOME:-x}`), grammar that spells the variable and the default's word for arg_values", "validatePattern": "exempt: reads registry patterns, not command words", "validEntryID": "exempt: reads a registry id, not a command word", } diff --git a/internal/termsafe/codespan_canonical_test.go b/internal/termsafe/codespan_canonical_test.go index 432df4434..672690da9 100644 --- a/internal/termsafe/codespan_canonical_test.go +++ b/internal/termsafe/codespan_canonical_test.go @@ -32,7 +32,7 @@ var backtickScanners = map[string]backtickScanner{ "internal/adapter/scanner/identity.go": {1, "a delimiter set: a backtick is one of the characters that may end an identity token; nothing is paired"}, "internal/core/capture/promote.go": {1, "a WRITER: codeSpan measures the longest backtick run to choose a fence the value cannot close; nothing is paired"}, "internal/core/guard/tokenize.go": {23, "the shell tokenizer: a backtick there is command substitution, a shell grammar, not markdown"}, - "internal/core/guard/unknown.go": {1, "spellAlternative spells an alternative's shell word: a backtick there opens a command substitution, which leaves the word unspelled; nothing is paired"}, + "internal/core/guard/unknown.go": {1, "spellWord spells a default's or an alternative's shell word: a backtick there opens a command substitution, whose output the spelling drops; nothing is paired"}, "internal/core/history/reconstruct_render.go": {1, "a WRITER: longestBacktickRun sizes a fence longer than any run in the body; nothing is paired"}, "internal/core/ideate/render.go": {1, "blockText asks whether a value opens with a backtick, then asks OpensBalancedCodeSpan, which pairs through PairCodeSpan"}, "internal/core/lifeboat/mdrender.go": {1, "escapeLeadingMarker asks whether a value opens with a backtick, then asks OpensBalancedCodeSpan, which pairs through PairCodeSpan"}, From cfe4d8392397c82a4319303159f9d818f79d8efa Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:26:01 +0100 Subject: [PATCH 02/69] chore(issues): capture a trim that can leave only the root A suffix trim whose pattern is unknown text or begins with a glob can leave only the leading slash of an absolute path (`${X%${X#?}}` and `${X%%[!/]*}` print `/` with X=/a/b on bash 3.2), and rm-rf-root-or-home reads the trim as its variable alone. Confirmed while fixing the default word; left for its own change because reading every trim as the root refuses the everyday `${DIR%/}` and `${f%.*}`, so the rule needs the pattern's shape. The record carries its remedy. Refs: iss-2609292320015665, iss-2609290426544292 Assisted-by: Claude:claude-opus-5-5 --- ...e-reads-a-trimmed-expansion-as-its-variable.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md diff --git a/.abcd/work/issues/open/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md b/.abcd/work/issues/open/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md new file mode 100644 index 000000000..c45c409cb --- /dev/null +++ b/.abcd/work/issues/open/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609292320015665" +slug: "rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "In spellParameterAt, spell a % or %% trim whose pattern begins with *, ?, [, $ or a backtick as the variable and / (the leading slash it can leave), keep a trim whose pattern begins with literal text as the variable alone, and pin ${DIR%/}, ${DIR%/*} and ${f%.*} as allowed, test first." +--- + +rm-rf-root-or-home reads a trimmed expansion as its variable alone, but a suffix trim whose pattern is unknown text or begins with a glob can leave only the leading slash of an absolute path: with X=/a/b, bash 3.2 prints / for ${X%${X#?}} and ${X%%[!/]*}, so rm -rf ${X%${X#?}} deletes the root and allows. Found while fixing iss-2609290426544292, whose written spelling now holds a set of texts; a trim can add / to that set, but reading every trim as the root would refuse the everyday ${DIR%/} and ${f%.*}, so the rule needs the pattern's shape. From 6b4516f505405d26d41a7bfd5ae9bc78eabf8df8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:26:13 +0100 Subject: [PATCH 03/69] =?UTF-8?q?chore:=20resolve=20iss-2609290426544292?= =?UTF-8?q?=20=E2=80=94=20a=20default's=20word=20reads=20as=20the=20home?= =?UTF-8?q?=20or=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609290426544292 Assisted-by: Claude:claude-opus-5-5 --- ...t-or-home-reads-a-default-expansion-by-its-variable.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md (80%) diff --git a/.abcd/work/issues/open/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md b/.abcd/work/issues/resolved/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md similarity index 80% rename from .abcd/work/issues/open/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md rename to .abcd/work/issues/resolved/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md index b3db44fb6..54bc2e555 100644 --- a/.abcd/work/issues/open/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md +++ b/.abcd/work/issues/resolved/iss-2609290426544292-rm-rf-root-or-home-reads-a-default-expansion-by-its-variable.md @@ -12,6 +12,10 @@ found_at: "internal/core/guard/unknown.go" remedy: "Make segment.spelled a set of the texts a word can print (varSite.texts, spellWritten), read a default's and an alternative's word through it (spellWord, the alternative also printing nothing), a substring's leading /, and a * replacement's string, pair every text through spellPayload, and bound depth and size so past either the word refuses, test first." deferred_after: v0.11.1 deferral_reason: "Reading a default's word needs a written spelling that holds more than one text (the variable's value or the default's word), which changes segment.spelled from one string per word to a set and the payload pairing that copies it (spellPayload); owed: that representation, then the default word, deep alternatives and a substring's root read through it, test first." +resolution: "rm-rf-root-or-home reads a word's written spelling as the set of texts it can print: a default's and an assignment's word (also after a subscript), an alternative's word or nothing, a substring's leading slash and a star replacement's string, followed 8 deep and 16 texts wide, past which the word refuses." +impact: fix +resolved_by: + commit: "62d1ab56f5e573564bc51ca37ffc67a6ae45ffe4" --- rm-rf-root-or-home reads a default expansion by its variable only: rm -rf ${DIR:-$HOME} and rm -rf ${DIR:-/} delete the home or the root when DIR is unset and allow, because a word's written spelling holds one text and the default's own word is the other value it can print. An alternative nested more than three deep (${X:+${X:+${X:+${X:+$HOME}}}}) and ${PWD:0:1}, which prints the root and warns as $PWD, are the same class. Named in 17-guard.md's residuals. @@ -23,3 +27,7 @@ Deferred past v0.11.1: Reading a default's word needs a written spelling that ho ## Evidence 2026-09-29: a default after a subscript The class includes a default the bash 3.2 of macOS reads at the first operator after a subscript's `]`. With X unset, bash 3.2 and /bin/sh print the word for `${X[0]]-$HOME}`, `${X[0]]:-$HOME}`, `${X[0]]=$HOME}`, `${X[0]]:=$HOME}` and `${X[0]]x-$HOME}` (the home), and for `${X[0]]-/}` (the root); bash 5 refuses each as a bad substitution. With X set each prints X's value. The guard reads the subscript's operator (unknown.go subscriptOperators) and spells a `-` or `=` there as the variable, as it spells `${X:-$HOME}`, so each allows. The pin `${X[0]]-$HOME}` in homeresiduals_test.go is this residual, not a claim that the form stays off the home. The same owed representation, a spelling that holds both texts, reads them. + +## Grounds + +- pursued: rm -rf ${DIR:-$HOME}, ${DIR:-/}, ${X[0]]-$HOME}, a four-deep alternative and ${PWD:0:1} block bare and in sh -c and bash -c while ${DIR:-./build} allows; a default, alternative or substring form that prints the root or home and still allows would show it wrong. From e8fa54588e1bfbc11ef488e7e1af3b676c88e815 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:10:09 +0100 Subject: [PATCH 04/69] chore(issues): capture the trim and replacement siblings of the root trim Two siblings of the trim that leaves only the root, confirmed on bash 3.2, /bin/sh and dash while fixing it: an expansion that prints nothing whatever the value is (`${X%%*}/` is the root), and a replacement whose pattern can take the whole value (`${X/\/*/$HOME}` is the home). Refs: iss-2609300009506126 Refs: iss-2609300009581165 Refs: iss-2609292320015665 Assisted-by: Claude:claude-opus-5-5 --- ...pansion-that-prints-nothing-as-its-variable.md | 15 +++++++++++++++ ...at-can-take-the-whole-value-as-its-variable.md | 15 +++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609300009506126-rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable.md create mode 100644 .abcd/work/issues/open/iss-2609300009581165-rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable.md diff --git a/.abcd/work/issues/open/iss-2609300009506126-rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable.md b/.abcd/work/issues/open/iss-2609300009506126-rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable.md new file mode 100644 index 000000000..629529556 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300009506126-rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300009506126" +slug: "rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "In spellParameterAt, add the empty text to a longest trim (%% or ##) whose pattern can match a whole absolute path (it can take any length, begins with *, a glob, unknown text or a literal /, and ends with *, a glob or unknown text), to a trim whose anchored end is a glob or unknown text, to a substring, and to a trim, replacement or substring after a subscript (bash 3.2 prints nothing there for a scalar); verified on /bin/bash 3.2, /bin/sh and /bin/dash; pin ${X%%*} alone, ${p%/*} and ${X%%.*}/ as allowed, test first." +--- + +rm-rf-root-or-home reads an expansion that prints nothing whatever the value is as its variable, so the text around it is never read as the whole word: with X=/a/b, bash 3.2, /bin/sh and dash print nothing for ${X%%*}, ${X##*}, ${X%%/*} and ${X:0:0}, so rm -rf ${X%%*}/ deletes the root and rm -rf $HOME/${X%%*} the home, and both allow. bash 3.2, the /bin/bash and /bin/sh of macOS, also prints nothing for a trim, a replacement or a substring after a scalar's subscript (${X[0]%zzz}/ is /). Found while fixing iss-2609292320015665, the trim that leaves only the root; an alternative's empty text was added by iss-2609290426544292, and these are its siblings. diff --git a/.abcd/work/issues/open/iss-2609300009581165-rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable.md b/.abcd/work/issues/open/iss-2609300009581165-rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable.md new file mode 100644 index 000000000..056de271c --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300009581165-rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300009581165" +slug: "rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "In spellParameterAt, read a replacement's pattern by the same shape as a trim's: where it can take any length, begins with *, a glob, unknown text or a literal / and ends with *, a glob or unknown text, add its string's texts; where it also begins (past its leading *) with a glob or unknown text and is not anchored with /#, add / before each; keep ${X/foo/$HOME}, ${DIR/#\\~/$HOME} and ${name//[^a-z]/} allowed; verified on /bin/bash 3.2 and /bin/sh (dash has no replacement), test first." +--- + +rm-rf-root-or-home reads a pattern replacement as its variable unless its pattern is only *, but a replacement whose pattern can match the whole of an absolute path prints its string in its place, and one whose pattern can match all of the path after its leading slash prints / and its string: with X=/a/b, bash 3.2 and /bin/sh print the home for ${X/\/*/$HOME}, ${X/?*/$HOME} and ${X/$X/~}, and / for ${X/${X#?}} and ${X//[!\/]*/}, and rm -rf of each allows. Found while fixing iss-2609292320015665; iss-2609290426544292 read only the *-only pattern and left the rest alone to keep ${DIR/#\~/$HOME} allowed. From d82049fb7e8be1e429a8eb91297119aa171e9901 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:20:42 +0100 Subject: [PATCH 05/69] fix(guard): read a trim or a replacement by its pattern's shape rm-rf-root-or-home read a trim as its variable alone, so a pattern that can take the rest of the value passed: with X=/a/b, bash 3.2, /bin/sh and dash print `/` for `${X%${X#?}}` and `${X%%[!/]*}`, and `rm -rf` of either allowed. readPattern now records a trim's or a replacement's pattern shape in one left-to-right pass (its first and last element, the same past any run of `*`, and whether it can take any length), and the spelling adds what that shape lets the expansion print whatever the value holds: - a suffix trim whose pattern can take any length and whose first element past its `*`s is a glob or unknown text (`$Y`, `${...}`, `$(...)`, a backtick), and a prefix trim whose last such element is, can leave only the root, and nothing (`${X%${X#?}}`, `${T##*[!/]}` with T=/tmp/x/); - a longest trim whose pattern can match the whole path prints nothing, so the text beside it is the word (`${X%%*}/` is the root); - a replacement whose pattern can match the whole path prints its string (`${X/?*/$HOME}` is the home), and one that can match all of it after the leading `/` prints `/` and its string (`${X/${X#?}}` is the root); - a substring can also print nothing, and bash 3.2 prints nothing for a trim, a replacement or a substring after a scalar's subscript (`${X[0]%zzz}/` is the root). The everyday forms keep their verdicts: `${DIR%/}`, `${f%.txt}`, `${p##*/}`, `${p%/*}`, `${p#$HOME/}`, `${X%?}`, `${X/foo/$HOME}`, `${DIR/#\~/$HOME}` and `${name//[^a-z]/}`. `${DIR%$HOME}`, pinned as allowed when a trim read as its variable alone, now blocks: its pattern is unknown text at the end it trims from; the pin keeps its intent as `${DIR#$HOME/}`. Every existing corpus line keeps its verdict (1070 lines, dumped through the binary before and after); the corpus gains 15 block and 14 quiet lines. Refs: iss-2609292320015665 Refs: iss-2609300009506126 Refs: iss-2609300009581165 Assisted-by: Claude:claude-opus-5-5 --- ...ads-a-trimmed-expansion-as-its-variable.md | 2 +- internal/core/guard/defaultword_test.go | 7 +- .../guard/testdata/corpus/adversarial.txt | 38 ++ internal/core/guard/trimroot_test.go | 164 +++++++ internal/core/guard/unknown.go | 405 ++++++++++++++++-- internal/termsafe/codespan_canonical_test.go | 2 +- 6 files changed, 588 insertions(+), 30 deletions(-) create mode 100644 internal/core/guard/trimroot_test.go diff --git a/.abcd/work/issues/open/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md b/.abcd/work/issues/open/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md index c45c409cb..9e962452b 100644 --- a/.abcd/work/issues/open/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md +++ b/.abcd/work/issues/open/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md @@ -9,7 +9,7 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/unknown.go" -remedy: "In spellParameterAt, spell a % or %% trim whose pattern begins with *, ?, [, $ or a backtick as the variable and / (the leading slash it can leave), keep a trim whose pattern begins with literal text as the variable alone, and pin ${DIR%/}, ${DIR%/*} and ${f%.*} as allowed, test first." +remedy: "In spellParameterAt, read a trim's pattern by its shape (readPattern): a suffix trim whose pattern can take any length (a *, unknown text or an extglob group) and whose first element past any run of * is a glob or unknown text ($, ${, $(, a backtick), and a prefix trim whose last such element is, spell as the variable, / and nothing; keep a trim whose anchored element is literal text, or whose pattern matches a fixed width, as the variable alone, and pin ${DIR%/}, ${f%.txt}, ${p##*/}, ${p%/*}, ${p#$HOME/} and ${X%?} as allowed, test first; verified on /bin/bash 3.2, /bin/sh and /bin/dash." --- rm-rf-root-or-home reads a trimmed expansion as its variable alone, but a suffix trim whose pattern is unknown text or begins with a glob can leave only the leading slash of an absolute path: with X=/a/b, bash 3.2 prints / for ${X%${X#?}} and ${X%%[!/]*}, so rm -rf ${X%${X#?}} deletes the root and allows. Found while fixing iss-2609290426544292, whose written spelling now holds a set of texts; a trim can add / to that set, but reading every trim as the root would refuse the everyday ${DIR%/} and ${f%.*}, so the rule needs the pattern's shape. diff --git a/internal/core/guard/defaultword_test.go b/internal/core/guard/defaultword_test.go index 2a5574c10..73e88f34f 100644 --- a/internal/core/guard/defaultword_test.go +++ b/internal/core/guard/defaultword_test.go @@ -123,8 +123,11 @@ func TestDefaultWordsTheWrittenCompareReads(t *testing.T) { {`rm -rf ${DIR:?$HOME}`, shellBare | shellSQ, VerdictAllow, ""}, {`rm -rf ${DIR?/}`, shellBare | shellSQ, VerdictAllow, ""}, {`rm -rf ${X[0]]?$HOME}`, shellBare | shellSQ, VerdictAllow, ""}, - // A trimmed default is the variable's, not the word's. - {`rm -rf ${DIR%$HOME}`, shellBare | shellSQ, VerdictAllow, ""}, + // A trim's pattern is not a word it prints: the home it names is + // taken off the value. (`${DIR%$HOME}` blocks for another reason: + // its pattern is unknown text at the end it trims from, which can + // leave the root, iss-2609292320015665.) + {`rm -rf ${DIR#$HOME/}`, shellBare | shellSQ, VerdictAllow, ""}, }) } diff --git a/internal/core/guard/testdata/corpus/adversarial.txt b/internal/core/guard/testdata/corpus/adversarial.txt index 4d64d7e51..d9f1f1692 100644 --- a/internal/core/guard/testdata/corpus/adversarial.txt +++ b/internal/core/guard/testdata/corpus/adversarial.txt @@ -174,6 +174,44 @@ quiet rm -rf ${X:+${X:+${X:+${X:+./build}}}} quiet sh -c "rm -rf ${DIR:-./build}" quiet echo ${X:+${X:+${X:+${X:+${X:+${X:+${X:+${X:+${X:+x}}}}}}}}} +# --- block: a trim or a replacement whose pattern can take the rest --------- +# iss-2609292320015665, iss-2609300009506126, iss-2609300009581165. A pattern +# that can take a remainder of any length, from the end a trim anchors at, can +# leave only the root or nothing; one that can match the whole value hands a +# replacement's string the value's place. bash 3.2 prints nothing for a trim +# after a scalar's subscript. +block rm -rf ${X%${X#?}} +block rm -rf "${X%${X#?}}" +block rm -rf ${X%%[!/]*} +block rm -rf ${X%$Y} +block rm -rf ${T#${T%?}} +block rm -rf ${T##*[!/]} +block rm -rf ${X%${X#?}}* +block rm -rf ${X%%*}/ +block rm -rf $HOME/${X%%*} +block rm -rf ${X[0]%zzz}/ +block rm -rf ${X/${X#?}} +block rm -rf ${X/?*/$HOME} +block rm -rf ${X/\/*/$HOME} +block sh -c "rm -rf ${X%${X#?}}" +block bash -c 'rm -rf ${X%%*}/' + +# --- quiet: the everyday trims and replacements ------------------------------ +quiet rm -rf ${DIR%/} +quiet rm -rf "${DIR%/}/build" +quiet rm -rf ${f%.txt} +quiet rm -rf ${f%.*} +quiet rm -rf ${p##*/} +quiet rm -rf "$HOME/${d##*/}" +quiet rm -rf ${p%/*} +quiet rm -rf "${p%/*}/build" +quiet rm -rf "$HOME/${p#$HOME/}" +quiet rm -rf ${X%?} +quiet rm -rf ${X%%*} +quiet rm -rf ${X%%/*} +quiet rm -rf ${name//[^a-z]/} +quiet rm -rf ${X/foo/$HOME} + # --- fp: accepted false positives, and the exact shape they take ------------ fp rg git push --force docs/ fp grep -rn gh repo delete . diff --git a/internal/core/guard/trimroot_test.go b/internal/core/guard/trimroot_test.go new file mode 100644 index 000000000..a94b918a4 --- /dev/null +++ b/internal/core/guard/trimroot_test.go @@ -0,0 +1,164 @@ +package guard + +import ( + "strings" + "testing" +) + +// TestTrimsThatCanLeaveTheRootTheWrittenCompareReads — iss-2609292320015665. +// A trim takes a prefix or a suffix off the value, and what it leaves depends +// on the shape of its pattern. One whose pattern can take a remainder of any +// length the line does not spell, from the end the trim anchors at, can leave +// only the `/` an absolute path begins with, or the one a directory's value +// ends with: with X=/a/b and T=/tmp/x/, bash 3.2, /bin/sh and dash print `/` +// for `${X%${X#?}}`, `${X%%[!/]*}`, `${T#${T%?}}` and `${T##*[!/]}`. The +// everyday trims, whose pattern is literal text or a glob that meets literal +// text at that end, stay allowed: `${DIR%/}`, `${f%.txt}`, `${p##*/}` and +// `${p%/*}` never leave the root on their own. +func TestTrimsThatCanLeaveTheRootTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + // A suffix trim whose pattern begins with unknown text. + {`rm -rf ${X%${X#?}}`, all, VerdictBlock, home}, + {`rm -rf ${X%%${X#?}}`, all, VerdictBlock, home}, + {`rm -rf "${X%${X#?}}"`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X%"${X#?}"}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X%$Y}`, all, VerdictBlock, home}, + {`rm -rf ${X%%$Y*}`, all, VerdictBlock, home}, + {`rm -rf ${X%*${X#?}}`, all, VerdictBlock, home}, + {`rm -rf ${X%$(echo a/b)}`, shellBare | shellSQ, VerdictBlock, home}, + {"rm -rf ${X%`echo a/b`}", shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${DIR%$HOME}`, all, VerdictBlock, home}, + // A suffix trim whose pattern begins with a glob and can take any length. + {`rm -rf ${X%%[!/]*}`, all, VerdictBlock, home}, + {`rm -rf ${X%%""[!/]*}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X%%?*}/`, all, VerdictBlock, home}, + // A prefix trim whose pattern ends with unknown text or such a glob. + {`rm -rf ${T#${T%?}}`, all, VerdictBlock, home}, + {`rm -rf ${T##*[!/]}`, all, VerdictBlock, home}, + {`rm -rf ${T##$Y}`, all, VerdictBlock, home}, + // The root it leaves, with the text around it. + {`rm -rf ${X%${X#?}}*`, all, VerdictBlock, home}, + {`rm -rf ${X%%[!/]*}*/`, all, VerdictBlock, home}, + {`rm -rf ${HOME%${HOME#?}}`, all, VerdictBlock, home}, + {`rm -rf $HOME${X%${X#?}}`, all, VerdictBlock, home}, + // An element of an array, trimmed the same way. + {`rm -rf ${A[1]%${A[1]#?}}`, all, VerdictBlock, home}, + // The everyday trims: literal text, or a glob that meets literal text + // at the end the trim anchors at, or one of a fixed width. + {`rm -rf ${DIR%/}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${DIR%/}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${DIR%/}/build"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${f%.txt}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${f%.*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${f%%.*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${p##*/}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "$HOME/${d##*/}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${p%/*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${p%/*}/build"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${p#*/}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${p##*.}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${p#$HOME/}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "$HOME/${p#$HOME/}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%?}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X#?}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%\*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%"*"}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%x$Y}`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestExpansionsThatPrintNothingTheWrittenCompareReads — iss-2609300009506126. +// Some expansions print nothing whatever the value is, and the text around +// them is then the whole word: with X=/a/b, `${X%%*}`, `${X##*}`, `${X%%/*}` +// and `${X:0:0}` print nothing on bash 3.2, /bin/sh and dash, so +// `rm -rf ${X%%*}/` deletes the root. bash 3.2, the /bin/bash and /bin/sh of +// macOS, prints nothing too for a trim, a replacement or a substring after a +// scalar's subscript (`${X[0]%zzz}`). An operand the expansion is the whole +// of stays allowed: a word that prints nothing is no operand. +func TestExpansionsThatPrintNothingTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf ${X%%*}/`, all, VerdictBlock, home}, + {`rm -rf ${X##*}/*`, all, VerdictBlock, home}, + {`rm -rf $HOME/${X%%*}`, all, VerdictBlock, home}, + {`rm -rf ~/${X%%/*}`, all, VerdictBlock, home}, + {`rm -rf ${X##/*}/`, all, VerdictBlock, home}, + {`rm -rf ${X%${X}}/`, all, VerdictBlock, home}, + {`rm -rf ${X:0:0}/`, all, VerdictBlock, home}, + {`rm -rf $HOME${X:9}`, all, VerdictBlock, home}, + {`rm -rf ${X[0]%zzz}/`, all, VerdictBlock, home}, + {`rm -rf ${X[0]#zzz}/*`, all, VerdictBlock, home}, + {`rm -rf ${X[0]/a/b}/`, all, VerdictBlock, home}, + {`rm -rf ${X[0]:1}/`, all, VerdictBlock, home}, + // The expansion alone, and one beside a path that is not the root. + {`rm -rf ${X%%*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%%/*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%%*}/build`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${p%%/*}/build"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X[0]%zzz}/build`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X%%.*}/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${X%/*}/"`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestReplacementsThatCanTakeTheWholeValueTheWrittenCompareReads — +// iss-2609300009581165. A replacement whose pattern can match the whole of an +// absolute path, whatever it holds, prints its string in its place, and one +// whose pattern can match all of it after the leading `/` prints `/` and its +// string: with X=/a/b, `${X/\/*/$HOME}` and `${X/?*/$HOME}` print the home, +// and `${X/${X#?}}` and `${X//[!\/]*/}` print `/`. The everyday replacements, +// whose pattern is literal text at its start or matches a fixed width, stay +// allowed. +func TestReplacementsThatCanTakeTheWholeValueTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf ${X/${X#?}}`, all, VerdictBlock, home}, + {`rm -rf ${X/%${X#?}}`, all, VerdictBlock, home}, + {`rm -rf ${X//[!\/]*/}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X/\/*/$HOME}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X/?*/$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${X/$Y/~}`, all, VerdictBlock, home}, + {`rm -rf ${X/${X#?}/*}`, all, VerdictBlock, home}, + {`rm -rf ${X/*/}/`, all, VerdictBlock, home}, + {`rm -rf ${X/foo/$HOME}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${DIR/#\~/$HOME}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X/*/./build}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${name//[^a-z]/}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "./${X//\//_}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X/#$HOME/~}/build`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestPatternShapesStayLinear holds the pattern reading to the cost bar: a +// trim's or a replacement's pattern is read once, its nested expansions +// stepped over rather than read again, so a long or deeply nested pattern +// costs what its length does. +func TestPatternShapesStayLinear(t *testing.T) { + shapes := []struct { + name string + build func(int) string + }{ + {"long trim pattern", func(n int) string { + return "rm -rf ${X%" + strings.Repeat("[!/]*", n/5) + "}/" + }}, + {"nested trims", func(n int) string { + return "rm -rf " + strings.Repeat("${X%", n/4) + "x" + strings.Repeat("}", n/4) + }}, + {"many trims", func(n int) string { + return "rm -rf " + strings.Repeat("${X%${Y#?}}", n/11) + }}, + {"replacement patterns", func(n int) string { + return "rm -rf " + strings.Repeat(`${X/\/*/$HOME}`, n/14) + }}, + } + for _, s := range shapes { + t.Run(s.name, func(t *testing.T) { + assertWorkGrowth(t, s.build, 1<<11, "a pattern is read once") + }) + } +} diff --git a/internal/core/guard/unknown.go b/internal/core/guard/unknown.go index 4ab7ddf79..6ca1f9545 100644 --- a/internal/core/guard/unknown.go +++ b/internal/core/guard/unknown.go @@ -253,12 +253,16 @@ func paramText(text string) string { return strings.ReplaceAll(text, "\\\n", "") // message goes to the standard error, never into the word; // - a trimmed prefix or suffix and a pattern replacement (`${HOME%/}`, // `${HOME#x}`, `${HOME/x/y}`): the value when the pattern does not match, -// and what a suffix trim leaves otherwise is the path above it. A -// replacement whose pattern is only `*` replaces the whole value, so it -// also prints its string (`${X/*/$HOME}` is `${X}` and `$HOME`); -// - a substring (`${HOME:0}`), whose offset is arithmetic and can be 0, -// and which can print the `/` an absolute path begins with -// (`${PWD:0:1}` is `${PWD}` and `/`); +// and what a suffix trim leaves otherwise is the path above it. What +// else it can print is read from its pattern's shape (trimTexts, +// replacementTexts): a pattern that can take any remainder of the value +// can leave only the `/` an absolute path begins with (`${X%${X#?}}` is +// `${X}`, `/` and nothing), or the whole value, which a replacement's +// string takes the place of (`${X/*/$HOME}` and `${X/?*/$HOME}` are +// `${X}` and `$HOME`; iss-2609292320015665, iss-2609300009581165); +// - a substring (`${HOME:0}`), whose offset is arithmetic and can be 0 or +// past the end, and which can print the `/` an absolute path begins with +// (`${PWD:0:1}` is `${PWD}`, `/` and nothing); // - a case change (`${HOME^^}`, `${HOME@U}`), which names the same directory // on a case-insensitive disk, and `@E` and `@P`, which change no path; // - a subscript (`${HOME[0]}`, `${HOME[x[0]]}`), which can be 0, read to @@ -266,7 +270,10 @@ func paramText(text string) string { return strings.ReplaceAll(text, "\\\n", "") // over any text after it to the first operator byte // (subscriptOperators): an alternative there prints its word, a default // or an assignment the value or its word (`${X[0]]-$HOME}` is the home -// with X unset), and anything else the value (`${HOME[0]]}`, +// with X unset), a trim, a replacement or a substring what it prints +// after a name and also nothing, which is what bash 3.2 prints for one +// after a scalar's subscript (`${X[0]%x}` with X=/a/b; +// iss-2609300009506126), and anything else the value (`${HOME[0]]}`, // `${HOME[0]@Q}`). A subscript with no `]` cannot be read further. // // An alternative (`${X:+w}`, `${X+w}`) prints w or nothing, and is the texts @@ -360,36 +367,382 @@ func spellParameterAt(body string, depth int, split bool) []string { return orWord(rest[1:]) case strings.HasPrefix(rest, ":-") || strings.HasPrefix(rest, ":="): return orWord(rest[2:]) - case subscript, strings.HasPrefix(rest, ":?"): + case strings.HasPrefix(rest, ":?"): return value + } + var texts []string + switch { case rest[0] == ':': - // A substring: a part of the value, the whole of it at offset 0, and - // the `/` an absolute path begins with. - return []string{same, "/"} + // A substring: a part of the value, the whole of it at offset 0, the + // `/` an absolute path begins with, and nothing at an offset past + // its end (`${X:9}`) or a length of 0. + texts = []string{same, "/", ""} case rest[0] == '/': - // A pattern replacement, `/`, `//`, `/#` or `/%`. A pattern of only - // `*` matches the whole value, which its string replaces. - p := rest[1:] - if p != "" && strings.IndexByte("/#%", p[0]) >= 0 { - p = p[1:] - } - s := 0 - for s < len(p) && p[s] == '*' { - s++ - } - if s > 0 && s < len(p) && p[s] == '/' { - return orWord(p[s+1:]) - } + texts = replacementTexts(value, rest[1:], depth, split) + case rest[0] == '%' || rest[0] == '#': + texts = trimTexts(value, rest) + case subscript: return value - case strings.IndexByte("?#%^,~", rest[0]) >= 0: + case strings.IndexByte("?^,~", rest[0]) >= 0: // Every other operator that can print the value unchanged. return value case rest[0] == '@': if len(rest) == 2 && strings.IndexByte("EPULu", rest[1]) >= 0 { return value } + return raw + default: + return raw + } + if subscript { + // bash 3.2 prints nothing for a trim, a replacement or a substring + // after a scalar's subscript: `${X[0]%x}` with X=/a/b. + texts = appendText(texts, "") + } + return texts +} + +// trimTexts is the written spelling of a trim, whose operator and pattern +// are rest (`%p`, `%%p`, `#p`, `##p`), where value is the variable's own: +// the value, which the trim leaves where its pattern does not match, and +// what the pattern's shape (readPattern) lets it leave whatever the value +// holds (iss-2609292320015665, iss-2609300009506126). The rule, for a value +// that is an absolute path: +// +// - a suffix trim (`%`, `%%`) can leave only the leading `/` when its +// pattern can take a remainder of any length (it holds a `*`, unknown +// text or an extglob group) and its first element past any run of `*` +// is a glob or unknown text: that element can match the text after the +// `/`, and the rest of the pattern the remainder (`${X%${X#?}}`, +// `${X%%[!/]*}`, `${X%$Y}`). A prefix trim (`#`, `##`) can leave only a +// trailing `/` when the same holds of its last element (`${T#${T%?}}`, +// `${T##*[!/]}` with T=/tmp/x/). Either can then also leave nothing. +// - a longest trim (`%%`, `##`) can leave nothing when its pattern can +// match the whole path: it can take any length, its first element is a +// `*`, a glob, unknown text or a literal `/`, and its last a `*`, a glob +// or unknown text (`${X%%*}`, `${X%%/*}`, `${X##/*}`). +// +// Every other trim is the value alone. A pattern whose element at the +// anchored end is literal text, or which matches a fixed width, leaves the +// root or nothing only for a value of one particular content or length, as +// `rm -rf $X` deletes the root only for X=/: `${DIR%/}`, `${f%.txt}`, +// `${f%.*}`, `${p##*/}`, `${p%/*}`, `${p#$HOME/}`, `${X%?}` and `${X#?}`. +// A `*` at that end is stepped over, since it can match nothing, so a +// shortest trim reads as the element behind it; for a longest trim that +// over-reads (`${X%%*[!/]*}` leaves nothing, never `/`), which only adds a +// text. Unknown text is any expansion (`$Y`, `${…}`, `$(…)`, a backtick), +// quoted or not, `$HOME` included: its text is not in the line, and +// `${X%${X#?}}` builds it from the value itself. +func trimTexts(value []string, rest string) []string { + suffix := rest[0] == '%' + longest := len(rest) > 1 && rest[1] == rest[0] + p := rest[1:] + if longest { + p = rest[2:] + } + sh := readPattern(p, false) + anchored := sh.lastPast + if suffix { + anchored = sh.firstPast + } + texts := value + if sh.wide && roving(anchored) { + texts = appendText(appendText(texts, "/"), "") + } + if longest && sh.whole() { + texts = appendText(texts, "") + } + return texts +} + +// replacementTexts is the written spelling of a pattern replacement, whose +// text after the first `/` is rest (`p/s`, `/p/s`, `#p/s`, `%p/s`), where +// value is the variable's own: the value, and what the pattern's shape +// (readPattern) lets it print whatever the value holds +// (iss-2609300009581165). A pattern that can match the whole of an absolute +// path, by the longest-trim rule of trimTexts, prints the string s in its +// place (`${X/*/$HOME}`, `${X/\/*/$HOME}`, `${X/?*/$HOME}`, `${X/$Y/~}`), +// and one that can match all of it after the leading `/`, by the suffix-trim +// rule and ending in a `*`, a glob or unknown text, prints `/` and s, +// unless `/#` anchors it at the start (`${X/${X#?}}` and `${X//[!\/]*/}` +// are `/`). s is read as a default's word is (spellWord), and one that +// prints nothing the guard reads is the empty text. Every other replacement +// is the value alone: `${X/foo/$HOME}`, `${DIR/#\~/$HOME}`, and +// `${name//[^a-z]/}`, whose pattern matches one byte. +func replacementTexts(value []string, rest string, depth int, split bool) []string { + p := rest + anchor := byte(0) + if p != "" && strings.IndexByte("/#%", p[0]) >= 0 { + anchor, p = p[0], p[1:] + } + sh := readPattern(p, true) + whole := sh.whole() + tail := anchor != '#' && sh.wide && roving(sh.firstPast) && anyWidth(sh.last) + if !whole && !tail { + return value + } + s := "" + if sh.end < len(p) { + s = p[sh.end+1:] + } + words := spellWord(s, depth, split) + if len(words) == 0 { + words = []string{""} + } + texts := value + for _, w := range words { + if whole { + texts = appendText(texts, w) + } + if tail { + texts = appendText(texts, "/"+w) + } } - return raw + if capped(texts) || len(texts) > maxSpellings { + return []string{spellCapped} + } + return texts +} + +// patElem is one element of a trim's or a replacement's pattern, as far as +// what it can match decides what the expansion can print (readPattern). +type patElem uint8 + +const ( + // elemNone stands where a pattern has no element: an empty one. + elemNone patElem = iota + // elemLiteral is a byte the pattern matches as itself: plain, escaped + // or quoted, other than `/`. + elemLiteral + // elemSlash is a literal `/`. + elemSlash + // elemStar is an unquoted `*`, which matches any text, none included. + elemStar + // elemOne is a glob of one byte: a `?` or a bracket expression. + elemOne + // elemAny is text the line does not spell, of any length: an expansion + // (`$Y`, `${…}`, `$(…)`, a backtick, an ANSI-C string) or an extglob + // group (`@(…)`, `*(…)`), or a quote or an expansion that does not close. + elemAny +) + +// roving reports whether an element can match text the line does not spell: +// a one-byte glob or text of any length. +func roving(e patElem) bool { return e == elemOne || e == elemAny } + +// anyWidth reports whether an element can match whatever byte ends a value: +// a `*` or a roving element. +func anyWidth(e patElem) bool { return e == elemStar || roving(e) } + +// patternShape is what readPattern records of a pattern: its first and last +// element, the same past any run of `*` at that end, whether it can take a +// remainder of any length, and, for a replacement, where the `/` that ends +// it stands (len of the text where none does). +type patternShape struct { + first, last patElem + firstPast, lastPast patElem + wide bool + end int +} + +// whole reports whether the pattern can match the whole of an absolute +// path, whatever it holds: it can take any length, its first element can +// match the leading `/`, and its last can match whatever byte the path ends +// with. +func (sh patternShape) whole() bool { + return sh.wide && (sh.first == elemSlash || anyWidth(sh.first)) && anyWidth(sh.last) +} + +// readPattern reads the pattern p of a trim or, with replacement, of a +// replacement, once and left to right, recording its shape (patternShape). +// Its quotes and escapes make literal text; an expansion in it is stepped +// over to its close without being read again, so the cost is p's length. +// A replacement's pattern ends at its first unescaped `/`, which bash 3.2 +// reads as the end even inside quotes and brackets (`${X/[/]/c}` replaces +// `[`). +func readPattern(p string, replacement bool) patternShape { + tally(len(p)) + sh := patternShape{end: len(p)} + add := func(e patElem) { + if sh.first == elemNone { + sh.first = e + } + if e != elemStar && sh.firstPast == elemNone { + sh.firstPast = e + } + sh.last = e + if e != elemStar { + sh.lastPast = e + } + if e == elemStar || e == elemAny { + sh.wide = true + } + } + literal := func(c byte) { + if c == '/' { + add(elemSlash) + } else { + add(elemLiteral) + } + } + // unread marks the rest of the pattern as text the guard does not read. + unread := func() patternShape { + add(elemAny) + return sh + } + budget := 4*len(p) + 16 + dq := false + for i := 0; i < len(p); { + c := p[i] + switch { + case replacement && c == '/': + sh.end = i + return sh + case c == '\\': + if i+1 < len(p) { + literal(p[i+1]) + } else { + literal(c) + } + i += 2 + case c == '"': + dq = !dq + i++ + case c == '\'' && !dq: + k := strings.IndexByte(p[i+1:], '\'') + if k < 0 { + return unread() + } + for j := i + 1; j < i+1+k; j++ { + if replacement && p[j] == '/' { + sh.end = j + return sh + } + literal(p[j]) + } + i += k + 2 + case c == '$' && i+1 < len(p) && p[i+1] == '{': + end := closingDolBrace(p, i+2, &budget) + if end < 0 { + return unread() + } + add(elemAny) + i = end + 1 + case c == '$' && i+1 < len(p) && p[i+1] == '(': + end := closingParen(p, i+2, &budget) + if end < 0 { + return unread() + } + add(elemAny) + i = end + 1 + case c == '`': + end := closingBacktick(p, i+1, &budget) + if end < 0 { + return unread() + } + add(elemAny) + i = end + 1 + case c == '$' && !dq && i+1 < len(p) && p[i+1] == '\'': + k := i + 2 + for k < len(p) && p[k] != '\'' { + if p[k] == '\\' { + k++ + } + k++ + } + if k >= len(p) { + return unread() + } + add(elemAny) + i = k + 1 + case c == '$': + if end := simpleParamEnd(p, i+1); end > 0 { + add(elemAny) + i = end + continue + } + literal(c) + i++ + case dq: + literal(c) + i++ + case strings.IndexByte("*?+@!", c) >= 0 && i+1 < len(p) && p[i+1] == '(': + end := extglobEnd(p, i+1) + if end < 0 { + return unread() + } + add(elemAny) + i = end + 1 + case c == '*': + add(elemStar) + i++ + case c == '?': + add(elemOne) + i++ + case c == '[': + if end := bracketEnd(p, i, replacement); end > 0 { + add(elemOne) + i = end + 1 + continue + } + literal(c) + i++ + default: + literal(c) + i++ + } + } + return sh +} + +// bracketEnd returns the index of the `]` that closes the bracket +// expression opening at p[i], or -1 where none does: a `]` directly after +// the `[` or its `!` or `^` is a member, and a backslash quotes the next +// byte. In a replacement's pattern a `/` ends the pattern first +// (readPattern), and the `[` is then literal. +func bracketEnd(p string, i int, replacement bool) int { + j := i + 1 + if j < len(p) && (p[j] == '!' || p[j] == '^') { + j++ + } + if j < len(p) && p[j] == ']' { + j++ + } + for j < len(p) { + switch p[j] { + case '\\': + j += 2 + continue + case ']': + return j + case '/': + if replacement { + return -1 + } + } + j++ + } + return -1 +} + +// extglobEnd returns the index of the `)` that closes the extglob group +// whose `(` is at p[i], counting the groups nested in it, or -1 where none +// does. +func extglobEnd(p string, i int) int { + depth := 0 + for j := i; j < len(p); j++ { + switch p[j] { + case '\\': + j++ + case '(': + depth++ + case ')': + if depth--; depth == 0 { + return j + } + } + } + return -1 } // subscriptOperators are the bytes bash 3.2 stops at in the text after a diff --git a/internal/termsafe/codespan_canonical_test.go b/internal/termsafe/codespan_canonical_test.go index 672690da9..3d226d676 100644 --- a/internal/termsafe/codespan_canonical_test.go +++ b/internal/termsafe/codespan_canonical_test.go @@ -32,7 +32,7 @@ var backtickScanners = map[string]backtickScanner{ "internal/adapter/scanner/identity.go": {1, "a delimiter set: a backtick is one of the characters that may end an identity token; nothing is paired"}, "internal/core/capture/promote.go": {1, "a WRITER: codeSpan measures the longest backtick run to choose a fence the value cannot close; nothing is paired"}, "internal/core/guard/tokenize.go": {23, "the shell tokenizer: a backtick there is command substitution, a shell grammar, not markdown"}, - "internal/core/guard/unknown.go": {1, "spellWord spells a default's or an alternative's shell word: a backtick there opens a command substitution, whose output the spelling drops; nothing is paired"}, + "internal/core/guard/unknown.go": {2, "spellWord spells a default's or an alternative's shell word, and readPattern reads a trim's or a replacement's pattern: a backtick in either opens a command substitution, whose output the spelling drops or the pattern reads as unknown text; nothing is paired"}, "internal/core/history/reconstruct_render.go": {1, "a WRITER: longestBacktickRun sizes a fence longer than any run in the body; nothing is paired"}, "internal/core/ideate/render.go": {1, "blockText asks whether a value opens with a backtick, then asks OpensBalancedCodeSpan, which pairs through PairCodeSpan"}, "internal/core/lifeboat/mdrender.go": {1, "escapeLeadingMarker asks whether a value opens with a backtick, then asks OpensBalancedCodeSpan, which pairs through PairCodeSpan"}, From 358e0a81b5486f83dd68a2f970b19d56a8d960b4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:21:00 +0100 Subject: [PATCH 06/69] chore: resolve iss-2609292320015665 and its two siblings The trim that leaves only the root, the expansion that prints nothing whatever the value holds, and the replacement that takes the whole value are read by their pattern's shape (d82049fb7). Resolves: iss-2609292320015665 Resolves: iss-2609300009506126 Resolves: iss-2609300009581165 Assisted-by: Claude:claude-opus-5-5 --- ...t-or-home-reads-a-trimmed-expansion-as-its-variable.md | 8 ++++++++ ...ds-an-expansion-that-prints-nothing-as-its-variable.md | 8 ++++++++ ...ement-that-can-take-the-whole-value-as-its-variable.md | 8 ++++++++ 3 files changed, 24 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md (66%) rename .abcd/work/issues/{open => resolved}/iss-2609300009506126-rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable.md (71%) rename .abcd/work/issues/{open => resolved}/iss-2609300009581165-rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable.md (68%) diff --git a/.abcd/work/issues/open/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md b/.abcd/work/issues/resolved/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md similarity index 66% rename from .abcd/work/issues/open/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md rename to .abcd/work/issues/resolved/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md index 9e962452b..bfb5e12e3 100644 --- a/.abcd/work/issues/open/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md +++ b/.abcd/work/issues/resolved/iss-2609292320015665-rm-rf-root-or-home-reads-a-trimmed-expansion-as-its-variable.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/unknown.go" remedy: "In spellParameterAt, read a trim's pattern by its shape (readPattern): a suffix trim whose pattern can take any length (a *, unknown text or an extglob group) and whose first element past any run of * is a glob or unknown text ($, ${, $(, a backtick), and a prefix trim whose last such element is, spell as the variable, / and nothing; keep a trim whose anchored element is literal text, or whose pattern matches a fixed width, as the variable alone, and pin ${DIR%/}, ${f%.txt}, ${p##*/}, ${p%/*}, ${p#$HOME/} and ${X%?} as allowed, test first; verified on /bin/bash 3.2, /bin/sh and /bin/dash." +resolution: "readPattern reads a trim's pattern shape; a suffix trim whose pattern can take any length and begins (past its *s) with a glob or unknown text, and a prefix trim that ends so, spell as the variable, / and nothing, so rm -rf ${X%${X#?}} and ${X%%[!/]*} block while ${DIR%/}, ${f%.txt}, ${p##*/} and ${p%/*} stay allowed" +impact: fix +resolved_by: + commit: "d82049fb7e8be1e429a8eb91297119aa171e9901" --- rm-rf-root-or-home reads a trimmed expansion as its variable alone, but a suffix trim whose pattern is unknown text or begins with a glob can leave only the leading slash of an absolute path: with X=/a/b, bash 3.2 prints / for ${X%${X#?}} and ${X%%[!/]*}, so rm -rf ${X%${X#?}} deletes the root and allows. Found while fixing iss-2609290426544292, whose written spelling now holds a set of texts; a trim can add / to that set, but reading every trim as the root would refuse the everyday ${DIR%/} and ${f%.*}, so the rule needs the pattern's shape. + +## Grounds + +- pursued: every block form in TestTrimsThatCanLeaveTheRootTheWrittenCompareReads blocks bare and through bash -c and sh -c, each verified to print / on /bin/bash 3.2, /bin/sh and /bin/dash; a trim printing / that the shape rule reads as literal text at its anchored end, other than for one particular value, would show it wrong diff --git a/.abcd/work/issues/open/iss-2609300009506126-rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable.md b/.abcd/work/issues/resolved/iss-2609300009506126-rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable.md similarity index 71% rename from .abcd/work/issues/open/iss-2609300009506126-rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable.md rename to .abcd/work/issues/resolved/iss-2609300009506126-rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable.md index 629529556..05bf9b255 100644 --- a/.abcd/work/issues/open/iss-2609300009506126-rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable.md +++ b/.abcd/work/issues/resolved/iss-2609300009506126-rm-rf-root-or-home-reads-an-expansion-that-prints-nothing-as-its-variable.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/unknown.go" remedy: "In spellParameterAt, add the empty text to a longest trim (%% or ##) whose pattern can match a whole absolute path (it can take any length, begins with *, a glob, unknown text or a literal /, and ends with *, a glob or unknown text), to a trim whose anchored end is a glob or unknown text, to a substring, and to a trim, replacement or substring after a subscript (bash 3.2 prints nothing there for a scalar); verified on /bin/bash 3.2, /bin/sh and /bin/dash; pin ${X%%*} alone, ${p%/*} and ${X%%.*}/ as allowed, test first." +resolution: "a longest trim whose pattern can match the whole path, a substring, and a trim, replacement or substring after a subscript now also spell as nothing, so rm -rf ${X%%*}/ and ${X[0]%zzz}/ block while the expansion alone stays allowed" +impact: fix +resolved_by: + commit: "d82049fb7e8be1e429a8eb91297119aa171e9901" --- rm-rf-root-or-home reads an expansion that prints nothing whatever the value is as its variable, so the text around it is never read as the whole word: with X=/a/b, bash 3.2, /bin/sh and dash print nothing for ${X%%*}, ${X##*}, ${X%%/*} and ${X:0:0}, so rm -rf ${X%%*}/ deletes the root and rm -rf $HOME/${X%%*} the home, and both allow. bash 3.2, the /bin/bash and /bin/sh of macOS, also prints nothing for a trim, a replacement or a substring after a scalar's subscript (${X[0]%zzz}/ is /). Found while fixing iss-2609292320015665, the trim that leaves only the root; an alternative's empty text was added by iss-2609290426544292, and these are its siblings. + +## Grounds + +- pursued: TestExpansionsThatPrintNothingTheWrittenCompareReads blocks each form bash 3.2 and /bin/sh print as the root or the home and allows the expansion standing alone; a structurally empty expansion whose neighbour text still reads only as the variable would show it wrong diff --git a/.abcd/work/issues/open/iss-2609300009581165-rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable.md b/.abcd/work/issues/resolved/iss-2609300009581165-rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable.md similarity index 68% rename from .abcd/work/issues/open/iss-2609300009581165-rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable.md rename to .abcd/work/issues/resolved/iss-2609300009581165-rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable.md index 056de271c..163d98de9 100644 --- a/.abcd/work/issues/open/iss-2609300009581165-rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable.md +++ b/.abcd/work/issues/resolved/iss-2609300009581165-rm-rf-root-or-home-reads-a-replacement-that-can-take-the-whole-value-as-its-variable.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/unknown.go" remedy: "In spellParameterAt, read a replacement's pattern by the same shape as a trim's: where it can take any length, begins with *, a glob, unknown text or a literal / and ends with *, a glob or unknown text, add its string's texts; where it also begins (past its leading *) with a glob or unknown text and is not anchored with /#, add / before each; keep ${X/foo/$HOME}, ${DIR/#\\~/$HOME} and ${name//[^a-z]/} allowed; verified on /bin/bash 3.2 and /bin/sh (dash has no replacement), test first." +resolution: "replacementTexts reads a replacement's pattern by the trim's shape rule: one that can match the whole path adds its string, one that can match all of it after the leading slash adds / and its string, so rm -rf ${X/?*/$HOME} and ${X/${X#?}} block while ${X/foo/$HOME} and ${DIR/#\\~/$HOME} stay allowed" +impact: fix +resolved_by: + commit: "d82049fb7e8be1e429a8eb91297119aa171e9901" --- rm-rf-root-or-home reads a pattern replacement as its variable unless its pattern is only *, but a replacement whose pattern can match the whole of an absolute path prints its string in its place, and one whose pattern can match all of the path after its leading slash prints / and its string: with X=/a/b, bash 3.2 and /bin/sh print the home for ${X/\/*/$HOME}, ${X/?*/$HOME} and ${X/$X/~}, and / for ${X/${X#?}} and ${X//[!\/]*/}, and rm -rf of each allows. Found while fixing iss-2609292320015665; iss-2609290426544292 read only the *-only pattern and left the rest alone to keep ${DIR/#\~/$HOME} allowed. + +## Grounds + +- pursued: TestReplacementsThatCanTakeTheWholeValueTheWrittenCompareReads blocks each form bash 3.2 and /bin/sh print as the root or the home and keeps the everyday replacements allowed; a replacement whose whole-value pattern still reads as the variable alone would show it wrong From 3b3507cfedcad25d48b1c1152fc91549e281ee13 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:45:10 +0100 Subject: [PATCH 07/69] feat(release): a cut moves every target it passes to `next` Criterion 3 of itd-2609212103572513, as the product thinker ruled it on 2026-09-29 (BS1): when a release goes out without an intent targeted at it, the target becomes `next`, whatever the following release is numbered, never a version number. - launch.MissedTargets picks the targets a cut passes: `next` (it named the release being cut) and a tag at or below the derived version. A tag above the cut is still ahead and stays; an illegal value is left for the record lint. - The ingest (the write that rolls the changelog) rewrites each such record's `target_release` to `next` under the intent store's lock, first among the cut's writes, and restores it on the cut's undo. A record whose target changed since the cut read it stops the cut. - The dated section names the move in one line under its notice, ahead of the first change-type heading (changelog.TargetMoveNote), so delivery_state does not judge it. The site's release stamp (releaseOf) passes over that line, because it names intents the release did not ship. - `launch ship` prints one `moved:` line per intent; the JSON carries `moved_targets`. Found at the base: the cut's writes are release.Ingest (launch.Ship has no production caller), so the move lives there, and `next` was already admitted by the verb and the lint (AC1 built earlier); a lint test pins it. Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/04-launch.md | 8 +- .../brief/04-surfaces/05-intent.md | 4 +- commands/intent.md | 5 +- commands/launch.md | 18 ++- internal/core/changelog/targetmove.go | 46 +++++++ internal/core/changelog/targetmove_test.go | 35 +++++ internal/core/intent/target.go | 62 +++++++++ internal/core/intent/target_test.go | 58 ++++++++ internal/core/launch/target.go | 43 ++++++ internal/core/launch/target_test.go | 36 +++++ internal/core/release/ingest.go | 39 +++++- internal/core/release/targetmove_test.go | 125 ++++++++++++++++++ internal/core/release/write.go | 24 +++- internal/core/site/compose.go | 8 ++ internal/core/site/compose_test.go | 37 ++++++ .../surface/cli/intent_target_cli_test.go | 12 ++ internal/surface/cli/ship.go | 5 + 17 files changed, 554 insertions(+), 11 deletions(-) create mode 100644 internal/core/changelog/targetmove.go create mode 100644 internal/core/changelog/targetmove_test.go create mode 100644 internal/core/release/targetmove_test.go diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index bb1c737c2..6108f0567 100644 --- a/.abcd/development/brief/04-surfaces/04-launch.md +++ b/.abcd/development/brief/04-surfaces/04-launch.md @@ -648,7 +648,13 @@ the parity diff and the deep smoke tier where the run made them, and every planned intent that names a release it must land by, under *Targeted, not shipped* (itd-2609212103572513): the preview, the cut's emit and its ingest list the same intents in their human and machine-readable output too, and none of -them refuses on one. +them refuses on one. The ingest moves every target the cut passes (`next`, or a +tag at or below the derived version) to `next`, whatever the following release +is numbered (the product thinker's ruling BS1 of 2026-09-29), rewriting the +record in the write that rolls the changelog and naming the move in one line +under the dated section's notice (`changelog.TargetMoveNote`), which the site's +release stamp passes over because the line names intents the release did not +ship. A refused cut writes its report too, and the refusal names where it landed. The preview's JSON carries `report_path`, the cut's `preflight_report`. A detector fails the build if any non-test Go source under `internal/` so much as names the diff --git a/.abcd/development/brief/04-surfaces/05-intent.md b/.abcd/development/brief/04-surfaces/05-intent.md index 591b56729..51e985dee 100644 --- a/.abcd/development/brief/04-surfaces/05-intent.md +++ b/.abcd/development/brief/04-surfaces/05-intent.md @@ -69,7 +69,7 @@ judgement no verb makes. - **ID format:** `itd-N` (unpadded — e.g., `itd-1`, `itd-15`). Mirrors the spec store's `spc-N` format. Filenames: `itd-N-.md`. Lexical-vs-numeric sort handled at the tool layer (`internal/core/lint`, registries) rather than via filename padding. - **The low IDs (itd-1..itd-7) reflect an early one-time rebase to ordering signal.** Intents created since are capture-stable, picking up at itd-27+. ID number is *not* an execution-order guarantee — the canonical build order is the phase plan at [`roadmap/phases/`](../../roadmap/phases/README.md). -- **An intent carries one release field, and only while planned: `target_release`.** A planned intent may name the release it must land by, `vX.Y.Z` or `next`, written by the target sub-verb or by planning with a target ([adr-2609212115255771](../../decisions/adrs/2609212115255771-phases-and-milestones-are-retired-sequencing-is-dependencies.md) decision 3, itd-2609212103572513). The launch preview and the cut list every targeted intent still in `planned/`, in their human and machine-readable output and in the pre-flight report, and neither refuses on one. Every move out of `planned/` drops the line — the spec close, the bundle close and the supersession — and record-lint's `record_schema` rule refuses it on a shipped or superseded intent, and refuses a value that is neither shape. +- **An intent carries one release field, and only while planned: `target_release`.** A planned intent may name the release it must land by, `vX.Y.Z` or `next`, written by the target sub-verb or by planning with a target ([adr-2609212115255771](../../decisions/adrs/2609212115255771-phases-and-milestones-are-retired-sequencing-is-dependencies.md) decision 3, itd-2609212103572513). The launch preview and the cut list every targeted intent still in `planned/`, in their human and machine-readable output and in the pre-flight report, and neither refuses on one. The cut moves every target it passes — `next`, or a tag at or below the release it cuts — to `next`, whatever the following release is numbered (the product thinker's ruling BS1 of 2026-09-29), in the same write as the dated changelog section, which names the move in one line under its notice. Every move out of `planned/` drops the line — the spec close, the bundle close and the supersession — and record-lint's `record_schema` rule refuses it on a shipped or superseded intent, and refuses a value that is neither shape. ### Intent kinds (per [`01-product/03-mental-model.md`](../01-product/03-mental-model.md)) @@ -174,7 +174,7 @@ its own condition rather than one still waiting on it. ### Lifecycle - **The low IDs (itd-1..itd-7) reflect an early one-time rebase to ordering signal.** Intents created since are capture-stable, picking up at itd-27+. ID number is *not* an execution-order guarantee — the canonical build order is the phase plan at [`roadmap/phases/`](../../roadmap/phases/README.md). -- **An intent carries one release field, and only while planned: `target_release`.** A planned intent may name the release it must land by, `vX.Y.Z` or `next`, written by the target sub-verb or by planning with a target ([adr-2609212115255771](../../decisions/adrs/2609212115255771-phases-and-milestones-are-retired-sequencing-is-dependencies.md) decision 3, itd-2609212103572513). The launch preview and the cut list every targeted intent still in `planned/`, in their human and machine-readable output and in the pre-flight report, and neither refuses on one. Every move out of `planned/` drops the line — the spec close, the bundle close and the supersession — and record-lint's `record_schema` rule refuses it on a shipped or superseded intent, and refuses a value that is neither shape. +- **An intent carries one release field, and only while planned: `target_release`.** A planned intent may name the release it must land by, `vX.Y.Z` or `next`, written by the target sub-verb or by planning with a target ([adr-2609212115255771](../../decisions/adrs/2609212115255771-phases-and-milestones-are-retired-sequencing-is-dependencies.md) decision 3, itd-2609212103572513). The launch preview and the cut list every targeted intent still in `planned/`, in their human and machine-readable output and in the pre-flight report, and neither refuses on one. The cut moves every target it passes — `next`, or a tag at or below the release it cuts — to `next`, whatever the following release is numbered (the product thinker's ruling BS1 of 2026-09-29), in the same write as the dated changelog section, which names the move in one line under its notice. Every move out of `planned/` drops the line — the spec close, the bundle close and the supersession — and record-lint's `record_schema` rule refuses it on a shipped or superseded intent, and refuses a value that is neither shape. - **Lifecycle (automated, not user-managed):** ``` diff --git a/commands/intent.md b/commands/intent.md index 3a0b77a5a..f1408952a 100644 --- a/commands/intent.md +++ b/commands/intent.md @@ -674,7 +674,10 @@ intent only: a bundle is planned without one and each member targeted after); Refused with nothing written: a draft, a shipped, superseded or discipline record, and a value that is neither shape. A target is a report, never a gate: `launch --dry-run` and the release cut (`launch ship`, `abcd changelog`) list -every targeted intent still planned, and neither refuses on one. Closing the +every targeted intent still planned, and neither refuses on one. The cut moves +every target it passes — `next`, or a tag at or below the release it cuts — to +`next`, whatever the following release is numbered, in the same write as the +changelog, and the dated section names the move. Closing the spec that ships the intent drops the line, as superseding it does, and record-lint's `record_schema` rule refuses a `target_release` left on a shipped or superseded intent. diff --git a/commands/launch.md b/commands/launch.md index 0474b9ad9..6300dc3a3 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -18,8 +18,9 @@ Two flows over the abcd binary, kept apart on purpose: `.abcd/.work.local/logs/launch/`. - **ship** — the release cut: derive the version from what shipped, compose the changelog prose and the release page, write them. It writes the dated section - of `CHANGELOG.md`, the release page `RELEASE.md`, and the outgoing page's copy - under `.abcd/development/releases/`; in a repository that publishes a versioned + of `CHANGELOG.md`, the release page `RELEASE.md`, the outgoing page's copy + under `.abcd/development/releases/`, and the `target_release` line of every + planned intent whose target the cut passed, moved to `next`; in a repository that publishes a versioned plugin it also pins the release's plugin archive in `.claude-plugin/marketplace.json` (refreshing the surface snapshot beside it). It **never publishes**. @@ -386,7 +387,8 @@ The cut also lists every planned intent that names a release it must land by (`targets`, one `targeted:` line each in the render, and `targets_error` when the intent store could not be read): targeted and not shipped. The list never refuses the cut and never changes the exit code; relay it with the report, and -the ingest in step 3 reports the same list beside what it wrote. +the ingest in step 3 reports the same list beside what it wrote, and moves +each target the cut passes to `next` (below). The emit render ends with the **receipts protocol**, a numbered checklist the binary composes from the committed `release.yml`: commit the roll, run each @@ -627,6 +629,16 @@ footer), and the page against the repository's persona registry On success it writes, in this order, only after every check has passed: +0. **the moved targets** — every planned intent whose `target_release` the cut + passes (`next`, which named this release, or a tag at or below the derived + one) targets `next` — the following release, whatever version it derives — + and its record is rewritten in the same write when it named a tag. A target + past the cut stays. The dated section names every move in one line under + its notice, ahead of the first change-type heading: `Targeted and not + shipped in this release, so each targets the next release (next): itd-N + (targeted vX.Y.Z), …`. A record whose target changed since the cut read it + stops the cut. The report prints one `moved:` line per intent and the JSON + carries `moved_targets` (`id`, `path`, `from`). 1. **the archive** — the outgoing `RELEASE.md` moves to `.abcd/development/releases/.md`, the version read from its own heading. It never overwrites: an archive page already standing there stops diff --git a/internal/core/changelog/targetmove.go b/internal/core/changelog/targetmove.go new file mode 100644 index 000000000..65afad34a --- /dev/null +++ b/internal/core/changelog/targetmove.go @@ -0,0 +1,46 @@ +package changelog + +// targetmove.go — the changelog's note of the targets a cut moved +// (itd-2609212103572513 criterion 3, ruling BS1 of 2026-09-29). +// +// A cut that passes a targeted intent without shipping it rewrites the target +// to `next` in the change that rolls the changelog, and the dated section says +// so in one line directly under its notice, ahead of every change-type +// heading. The line names planned intents, which this release did NOT ship, so +// every reader that credits a release with the records its section names — +// the site's release stamp — passes over it through IsTargetMoveNote. Its +// place ahead of the first `###` heading keeps it out of the delivery +// sections record-lint's delivery_state rule judges. + +import ( + "strings" + + "github.com/intentdriven/abcd/internal/core/launch" +) + +// targetMoveLead opens the note, spelled once for the writer and the reader. +const targetMoveLead = "Targeted and not shipped in this release, so each targets the next release (`next`): " + +// TargetMoveNote renders the note for the moves a cut makes, or "" when it +// makes none. Each move reads ` (targeted )`. +func TargetMoveNote(moves []launch.TargetMove) string { + if len(moves) == 0 { + return "" + } + parts := make([]string, 0, len(moves)) + for _, m := range moves { + from := m.From + if from == launch.TargetNext { + from = "`" + from + "`" + } + parts = append(parts, m.ID+" (targeted "+from+")") + } + return targetMoveLead + strings.Join(parts, ", ") + "." +} + +// IsTargetMoveNote reports whether a changelog line is a move note: a line +// that names intents a release did not ship, so no reader credits that +// release with them. +func IsTargetMoveNote(line string) bool { + return strings.HasPrefix(strings.TrimRight(line, "\r"), targetMoveLead) +} diff --git a/internal/core/changelog/targetmove_test.go b/internal/core/changelog/targetmove_test.go new file mode 100644 index 000000000..dcd4f7c50 --- /dev/null +++ b/internal/core/changelog/targetmove_test.go @@ -0,0 +1,35 @@ +package changelog + +import ( + "testing" + + "github.com/intentdriven/abcd/internal/core/launch" +) + +// TestTargetMoveNoteNamesEveryMove is criterion 3's changelog half +// (itd-2609212103572513, ruling BS1 of 2026-09-29): the note names each +// targeted intent the cut passed and the target it carried, says each targets +// `next`, and is the one line the note predicate recognises, so a reader that +// stamps a release onto the records a section credits can pass over it. +func TestTargetMoveNoteNamesEveryMove(t *testing.T) { + if got := TargetMoveNote(nil); got != "" { + t.Errorf("no move, no note: %q", got) + } + got := TargetMoveNote([]launch.TargetMove{ + {ID: "itd-7", Path: "p/itd-7.md", From: "v0.11.0"}, + {ID: "itd-9", Path: "p/itd-9.md", From: "next"}, + }) + want := "Targeted and not shipped in this release, so each targets the next release (`next`): " + + "itd-7 (targeted v0.11.0), itd-9 (targeted `next`)." + if got != want { + t.Errorf("TargetMoveNote =\n%q\nwant\n%q", got, want) + } + if !IsTargetMoveNote(got) { + t.Error("the predicate does not recognise the note the renderer writes") + } + for _, line := range []string{"", "- **A version is a fact.** Derived. (itd-7)", "### Added", "Targeted"} { + if IsTargetMoveNote(line) { + t.Errorf("IsTargetMoveNote(%q) = true", line) + } + } +} diff --git a/internal/core/intent/target.go b/internal/core/intent/target.go index 4f88ac843..ec5f3d66b 100644 --- a/internal/core/intent/target.go +++ b/internal/core/intent/target.go @@ -167,3 +167,65 @@ func intentNum(id string) int { } return n } + +// TargetRewrite is one intent record a cut rewrites as it moves a missed +// target to `next`: the record's repo-relative path and its bytes before and +// after, so the cut writes it with its other writes and restores it on their +// undo. +type TargetRewrite struct { + Path string + Before []byte + After []byte +} + +// PlanTargetMoves reads each record a cut passes (launch.MissedTargets) and +// returns the rewrite that moves its target to `next` (criterion 3, the +// product thinker's ruling BS1 of 2026-09-29). It writes nothing. A move whose +// target is already `next` needs no rewrite and yields none: it still names +// the following release after the cut. +// +// Every move is checked against the record as it is on disk, and one that no +// longer matches — a record not in planned/ under that path, or a target that +// moved since the cut read it — refuses the whole plan: the cut is not +// written from a stale read. The caller holds the store's lock (WithMintLock) +// across this read and its writes, as every other intent writer does. +func PlanTargetMoves(repoRoot string, moves []launch.TargetMove) ([]TargetRewrite, error) { + if len(moves) == 0 { + return nil, nil + } + corpus, err := Load(repoRoot) + if err != nil { + return nil, err + } + var out []TargetRewrite + for _, m := range moves { + it, ok := corpus.Lookup(m.ID) + if !ok || it.Bucket != BucketPlanned || filepath.ToSlash(it.Path) != m.Path { + return nil, fmt.Errorf("intent: %s is not the planned record at %s the cut read, so its target is not moved (nothing written)", m.ID, m.Path) + } + abs := filepath.Join(repoRoot, filepath.FromSlash(m.Path)) + data, err := readRepoFile(abs, m.Path) + if err != nil { + return nil, err + } + content := string(data) + current, malformed := targetField(frontmatter.Fields(strings.Split(content, "\n"))) + if malformed || current != m.From { + return nil, fmt.Errorf("intent: %s carries `%s: %s`, not the %s the cut read, so its target is not moved (nothing written)", + m.ID, launch.TargetReleaseKey, current, m.From) + } + if current == launch.TargetNext { + continue + } + updated, err := setFrontmatterFields(content, map[string]string{launch.TargetReleaseKey: launch.TargetNext}) + if err != nil { + return nil, err + } + if len(updated) > maxIntentFileBytes { + return nil, fmt.Errorf("intent: moving the target of %s would produce %d bytes, past the %d-byte cap its own reader enforces (nothing written)", + m.ID, len(updated), maxIntentFileBytes) + } + out = append(out, TargetRewrite{Path: m.Path, Before: data, After: []byte(updated)}) + } + return out, nil +} diff --git a/internal/core/intent/target_test.go b/internal/core/intent/target_test.go index d13abe866..1cd530934 100644 --- a/internal/core/intent/target_test.go +++ b/internal/core/intent/target_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/intentdriven/abcd/internal/core/launch" "github.com/intentdriven/abcd/internal/core/lint" ) @@ -330,3 +331,60 @@ func TestAMalformedTargetIsRefusedNotOverwritten(t *testing.T) { t.Fatalf("the listing must carry the malformed value raw and flagged: %+v", got) } } + +// Criterion 3's record half (ruling BS1 of 2026-09-29): the rewrite a cut +// makes for each target it passes. A version target becomes `next` on one +// line with nothing else in the file touched; a `next` target is already +// right and yields no rewrite; and a record whose target moved since the cut +// read it, or that left planned/, is refused rather than rewritten from a +// stale read. +func TestPlanTargetMovesRewritesAMissedTargetToNext(t *testing.T) { + root := t.TempDir() + relA := plannedDir + "/itd-10-alpha.md" + relB := plannedDir + "/itd-11-beta.md" + writeFile(t, root, relA, withTarget(plannedLinked("itd-10", "alpha", "spc-1"), "v0.11.0")) + writeFile(t, root, relB, withTarget(plannedLinked("itd-11", "beta", "spc-2"), "next")) + before := readRec(t, root, relA) + + got, err := PlanTargetMoves(root, []launch.TargetMove{ + {ID: "itd-10", Path: relA, From: "v0.11.0"}, + {ID: "itd-11", Path: relB, From: "next"}, + }) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Path != relA || string(got[0].Before) != before { + t.Fatalf("PlanTargetMoves = %+v, want one rewrite of %s", got, relA) + } + if want := withTarget(plannedLinked("itd-10", "alpha", "spc-1"), "next"); string(got[0].After) != want { + t.Fatalf("the rewrite must change the one line:\n%s\nwant\n%s", got[0].After, want) + } + if readRec(t, root, relA) != before { + t.Fatal("planning a move wrote the record") + } + + for _, tc := range []struct { + name string + move launch.TargetMove + want string + }{ + {"moved since the read", launch.TargetMove{ID: "itd-10", Path: relA, From: "v0.10.0"}, "v0.11.0"}, + {"not a planned record", launch.TargetMove{ID: "itd-10", Path: shippedDir + "/itd-10-alpha.md", From: "v0.11.0"}, "planned"}, + {"another record's file", launch.TargetMove{ID: "itd-12", Path: relA, From: "v0.11.0"}, "itd-12"}, + } { + if _, err := PlanTargetMoves(root, []launch.TargetMove{tc.move}); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: PlanTargetMoves = %v, want a refusal naming %q", tc.name, err, tc.want) + } + } +} + +// Criterion 1 with the ruled value: `next` on a planned intent is a legal +// target to the record lint as it is to the verb. +func TestTheRecordLintAdmitsNextOnAPlannedIntent(t *testing.T) { + root := t.TempDir() + writeFile(t, root, plannedDir+"/itd-13-next.md", withTarget(plannedLinked("itd-13", "next", "spc-7"), "next")) + writeFile(t, root, specsOpen+"/spc-7-next.md", specNaming("spc-7", "next", "itd-13")) + if fs := targetFindings(t, root); len(fs) != 0 { + t.Fatalf("`next` on a planned intent is legal: %+v", fs) + } +} diff --git a/internal/core/launch/target.go b/internal/core/launch/target.go index 6ad1ca160..3df594fa3 100644 --- a/internal/core/launch/target.go +++ b/internal/core/launch/target.go @@ -59,3 +59,46 @@ type TargetedIntent struct { // what refuses the value. Invalid string `json:"invalid,omitempty"` } + +// TargetMove is one targeted intent a cut passes without shipping it: the row +// the cut rewrites to `next` in the change that rolls the changelog, and the +// changelog's move note names (criterion 3, ruling BS1 of 2026-09-29). +type TargetMove struct { + // ID is the intent's id (itd-N). + ID string `json:"id"` + // Path is the record's repo-relative path. + Path string `json:"path"` + // From is the target the record carried before the cut: `next`, which + // named the release being cut, or a tag at or below it. + From string `json:"from"` +} + +// MissedTargets returns every target the cut to nextTag passes, in the order +// given: `next`, which names the release being cut, and a tag at or below +// nextTag, which names this release or one that will now never be cut. Each +// becomes `next` — the following release, whatever version it derives — so a +// missed target never goes stale and is never renumbered by guess (the product +// thinker's ruling BS1 of 2026-09-29). A tag above nextTag is still ahead and +// is not listed; neither is a value that is not a target (the record lint +// names it), nor anything when nextTag is empty, because a refused cut +// derives no version and writes nothing. +func MissedTargets(targets []TargetedIntent, nextTag string) []TargetMove { + cut, err := ParseSemver(strings.TrimPrefix(nextTag, "v")) + if err != nil || nextTag == "" { + return nil + } + var out []TargetMove + for _, tg := range targets { + if tg.Invalid != "" || ValidTargetRelease(tg.Target) != nil { + continue + } + if tg.Target != TargetNext { + v, err := ParseSemver(strings.TrimPrefix(tg.Target, "v")) + if err != nil || CoreGreater(v, cut) { + continue + } + } + out = append(out, TargetMove{ID: tg.ID, Path: tg.Path, From: tg.Target}) + } + return out +} diff --git a/internal/core/launch/target_test.go b/internal/core/launch/target_test.go index 80aca2b22..e910e9702 100644 --- a/internal/core/launch/target_test.go +++ b/internal/core/launch/target_test.go @@ -90,3 +90,39 @@ func TestPreflightReportWithNoTargetsSaysNothingAboutThem(t *testing.T) { t.Fatalf("no target, no section:\n%s", rep.Markdown()) } } + +// TestMissedTargetsAreTheOnesTheCutReaches is criterion 3's selection, as the +// product thinker ruled it on 2026-09-29 (BS1): a cut passes every target that +// names it or an earlier release — `next`, which names the release being cut, +// and a tag at or below the derived one — and each of those becomes `next`. A +// tag above the cut is still ahead and stays, and a value that is not a target +// is left for the record lint to name. +func TestMissedTargetsAreTheOnesTheCutReaches(t *testing.T) { + targets := []TargetedIntent{ + {ID: "itd-1", Path: "p/itd-1.md", Target: "next"}, + {ID: "itd-2", Path: "p/itd-2.md", Target: "v0.11.0"}, + {ID: "itd-3", Path: "p/itd-3.md", Target: "v0.10.2"}, + {ID: "itd-4", Path: "p/itd-4.md", Target: "v0.11.1"}, + {ID: "itd-5", Path: "p/itd-5.md", Target: "v1.0.0"}, + {ID: "itd-6", Path: "p/itd-6.md", Target: "0.11", Invalid: "not a target"}, + } + var got []string + for _, m := range MissedTargets(targets, "v0.11.0") { + got = append(got, m.ID+"="+m.From+"@"+m.Path) + } + if want := "itd-1=next@p/itd-1.md,itd-2=v0.11.0@p/itd-2.md,itd-3=v0.10.2@p/itd-3.md"; strings.Join(got, ",") != want { + t.Errorf("MissedTargets = %v, want %s", got, want) + } + // A breaking cut that skips a minor passes the minor it skipped. + got = nil + for _, m := range MissedTargets(targets, "v1.0.0") { + got = append(got, m.ID) + } + if want := "itd-1,itd-2,itd-3,itd-4,itd-5"; strings.Join(got, ",") != want { + t.Errorf("MissedTargets at v1.0.0 = %v, want %s", got, want) + } + // No derived version, no move: a refused cut carries none. + if m := MissedTargets(targets, ""); len(m) != 0 { + t.Errorf("a cut with no version moves nothing: %v", m) + } +} diff --git a/internal/core/release/ingest.go b/internal/core/release/ingest.go index a48f879e2..1550aeaf8 100644 --- a/internal/core/release/ingest.go +++ b/internal/core/release/ingest.go @@ -42,6 +42,8 @@ import ( "github.com/intentdriven/abcd/internal/adapter/scanner" "github.com/intentdriven/abcd/internal/core/changelog" + "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/core/launch" "github.com/intentdriven/abcd/internal/core/lint" "github.com/intentdriven/abcd/internal/core/surface" "github.com/intentdriven/abcd/internal/core/update" @@ -291,6 +293,11 @@ type IngestResult struct { // Page reports the release page: written (and what moved to the archive), or // why no page was written. Page PageResult `json:"page"` + // Moved lists every targeted intent the cut passed without shipping it, + // each rewritten to `next` in the same change unless it already named + // `next` (itd-2609212103572513 criterion 3, ruling BS1 of 2026-09-29), and + // named in the section's move note. + Moved []launch.TargetMove `json:"moved_targets,omitempty"` // Undo reverses the cut's writes. The ship verb applies it when a step after // the ingest refuses, so a refused ship leaves nothing behind. Undo UndoPlan `json:"-"` @@ -376,7 +383,10 @@ func ingest(root string, current surface.Snapshot, raw []byte, at time.Time, ops // verdict is returned: a fault here is a stop, and recomposing against it // would loop for nothing. heading := datedHeading(cut.NextTag, at) - section := renderSection(heading, entries) + // Each target this cut passes becomes `next` in the same change, and the + // section names the move (itd-2609212103572513 criterion 3, ruling BS1). + moves := launch.MissedTargets(cut.Targets, cut.NextTag) + section := renderSection(heading, entries, changelog.TargetMoveNote(moves)) content, before, err := insertSection(root, section) if err != nil { return res, err @@ -421,7 +431,23 @@ func ingest(root string, current surface.Snapshot, raw []byte, at time.Time, ops if hasPage { plan.page = []byte(pageText) } - undo, err := execute(ops, root, plan) + var undo UndoPlan + if len(moves) == 0 { + undo, err = execute(ops, root, plan) + } else { + // The records are read and rewritten under the intent store's lock, as + // every intent write is, so a target edited since the cut read it is + // refused rather than overwritten. + err = intent.WithMintLock(root, func() error { + rewrites, err := intent.PlanTargetMoves(root, moves) + if err != nil { + return err + } + plan.records = rewrites + undo, err = execute(ops, root, plan) + return err + }) + } if err != nil { return res, err } @@ -431,6 +457,7 @@ func ingest(root string, current surface.Snapshot, raw []byte, at time.Time, ops res.Heading = heading res.Lines = len(entries) res.Cited = required + res.Moved = moves res.Undo = undo if hasPage { res.Page = PageResult{ @@ -712,8 +739,14 @@ func datedHeading(nextTag string, at time.Time) string { // The notice sits directly under the heading, before any section, once: the // function renders exactly one cut, and each cut is its own dated section, so // idempotence across cuts holds by construction rather than by a scan. -func renderSection(heading string, entries []ChangelogEntry) []string { +func renderSection(heading string, entries []ChangelogEntry, moveNote string) []string { lines := []string{heading, "", sectionNotice, ""} + // The move note sits under the notice, ahead of every change-type heading: + // it names intents this release did not ship, so it is no entry of any + // section (changelog.TargetMoveNote). + if moveNote != "" { + lines = append(lines, moveNote, "") + } for _, section := range sectionOrder { var body []string for _, e := range entries { diff --git a/internal/core/release/targetmove_test.go b/internal/core/release/targetmove_test.go new file mode 100644 index 000000000..b3688e484 --- /dev/null +++ b/internal/core/release/targetmove_test.go @@ -0,0 +1,125 @@ +package release + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/changelog" + "github.com/intentdriven/abcd/internal/gittest" +) + +// targetedShippable is shippableRepo (a ready cut to v0.4.1) with three +// planned intents naming a release: one at the release being cut, one at +// `next`, and one at a later release the cut does not reach. +func targetedShippable(t *testing.T) *gittest.Repo { + t.Helper() + r := shippableRepo(t) + r.Write(plannedDir+"itd-91-due.md", "---\nid: itd-91\nimpact: additive\ntarget_release: v0.4.1\n---\n# Due\n") + r.Write(plannedDir+"itd-92-next.md", "---\nid: itd-92\nimpact: additive\ntarget_release: next\n---\n# Next\n") + r.Write(plannedDir+"itd-93-later.md", "---\nid: itd-93\nimpact: additive\ntarget_release: v0.5.0\n---\n# Later\n") + r.Commit("three planned intents name a release") + return r +} + +func readRel(t *testing.T, root, rel string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// TestIngestMovesAMissedTargetToNext is itd-2609212103572513 criterion 3 as +// the product thinker ruled it on 2026-09-29 (BS1): given the cut is written, +// when the changelog is rolled, then each unshipped target the cut reaches +// becomes `next` (whatever the following release is numbered) in the same +// change, and the changelog names the move. A target past the cut is left. +func TestIngestMovesAMissedTargetToNext(t *testing.T) { + r := targetedShippable(t) + root := r.Root() + ops := newRecordingOps(root) + res, err := ingest(root, liveSurface(), marshalPayload(t, "v0.4.1", goodEntries()), cutAt, ops) + if err != nil { + t.Fatalf("ingest: %v", err) + } + if !res.Written { + t.Fatalf("nothing was written; refusals = %v", refusalKinds(res.Cut)) + } + + if got := readRel(t, root, plannedDir+"itd-91-due.md"); got != "---\nid: itd-91\nimpact: additive\ntarget_release: next\n---\n# Due\n" { + t.Errorf("the missed version target must become `next`:\n%s", got) + } + if got := readRel(t, root, plannedDir+"itd-92-next.md"); !strings.Contains(got, "target_release: next\n") { + t.Errorf("a `next` target stays `next`:\n%s", got) + } + if got := readRel(t, root, plannedDir+"itd-93-later.md"); !strings.Contains(got, "target_release: v0.5.0\n") { + t.Errorf("a target past the cut is not moved:\n%s", got) + } + // The record rewrite is one of the cut's writes, ahead of the heading the + // tagging workflow reads. + want := []string{"replace " + plannedDir + "itd-91-due.md", "replace " + PageFile, "replace " + changelogFile} + if strings.Join(ops.log, "|") != strings.Join(want, "|") { + t.Errorf("writes = %v, want %v", ops.log, want) + } + + var moved []string + for _, m := range res.Moved { + moved = append(moved, m.ID+"="+m.From) + } + if strings.Join(moved, ",") != "itd-91=v0.4.1,itd-92=next" { + t.Errorf("Moved = %v", moved) + } + + note := "Targeted and not shipped in this release, so each targets the next release (`next`): " + + "itd-91 (targeted v0.4.1), itd-92 (targeted `next`)." + log := readChangelog(t, root) + if !strings.Contains(log, "## [0.4.1] - 2026-07-21\n\n"+sectionNotice+"\n\n"+note+"\n\n### Added\n") { + t.Errorf("the dated section must name the move under its notice, ahead of every change-type heading:\n%s", log) + } + if !changelog.IsTargetMoveNote(note) { + t.Error("the note written is not the one the readers pass over") + } +} + +// A cut that fails after the record rewrite puts the record back with the +// rest of the tree, and a later refusal's undo (the ship verb's payload +// render) does the same. +func TestTheMoveRollsBackWithTheCut(t *testing.T) { + r := targetedShippable(t) + root := r.Root() + before := treeDigest(t, root) + if _, err := ingest(root, liveSurface(), marshalPayload(t, "v0.4.1", goodEntries()), cutAt, newRecordingOps(root, "replace "+changelogFile)); err == nil { + t.Fatal("ingest succeeded through an injected failure") + } + if after := treeDigest(t, root); after != before { + t.Error("a failed cut left the moved target behind") + } + + res, err := Ingest(root, liveSurface(), marshalPayload(t, "v0.4.1", goodEntries()), cutAt) + if err != nil || !res.Written { + t.Fatalf("Ingest: %v (written=%v)", err, res.Written) + } + if failures := res.Undo.Apply(root); len(failures) > 0 { + t.Fatalf("undo: %v", failures) + } + if after := treeDigest(t, root); after != before { + t.Error("the cut's undo left the moved target behind") + } +} + +// A cut with no target reached writes no note and touches no record. +func TestIngestWithNoMissedTargetWritesNoNote(t *testing.T) { + r := shippableRepo(t) + r.Write(plannedDir+"itd-93-later.md", "---\nid: itd-93\nimpact: additive\ntarget_release: v0.5.0\n---\n# Later\n") + r.Commit("a target past the cut") + res, err := Ingest(r.Root(), liveSurface(), marshalPayload(t, "v0.4.1", goodEntries()), cutAt) + if err != nil || !res.Written { + t.Fatalf("Ingest: %v", err) + } + if len(res.Moved) != 0 || strings.Contains(readChangelog(t, r.Root()), "Targeted and not shipped") { + t.Errorf("no target reached, no move: %+v", res.Moved) + } +} diff --git a/internal/core/release/write.go b/internal/core/release/write.go index 642313bb3..3bf9c86a2 100644 --- a/internal/core/release/write.go +++ b/internal/core/release/write.go @@ -2,7 +2,9 @@ package release // write.go — the cut's writes, their order, and their undo. // -// A feature cut makes up to three writes, and they land together or not at all: +// A feature cut makes up to three writes, and they land together or not at all, +// after the rewrite of every intent record whose missed target the cut moves to +// `next` (itd-2609212103572513 criterion 3): // // 1. the ARCHIVE: the outgoing RELEASE.md's bytes, created under // .abcd/development/releases/.md, never overwriting; @@ -23,6 +25,7 @@ import ( "path/filepath" "strings" + "github.com/intentdriven/abcd/internal/core/intent" "github.com/intentdriven/abcd/internal/fsutil" ) @@ -62,6 +65,9 @@ func (o osOps) remove(rel string) error { // The ship verb applies it when a step AFTER the ingest refuses (the payload // render), so a refused ship leaves no release record behind. type UndoPlan struct { + // records are the intent records the cut rewrote (a missed target moved to + // `next`), each with its bytes before the write. + records []intent.TargetRewrite changelogBefore []byte pageBefore []byte pageExisted bool @@ -114,11 +120,20 @@ func (u UndoPlan) apply(ops fileOps, root string) []string { _ = os.Remove(filepath.Join(root, filepath.FromSlash(path.Dir(u.archived)))) } } + for i := len(u.records) - 1; i >= 0; i-- { + rec := u.records[i] + if err := ops.replace(rec.Path, rec.Before); err != nil { + failures = append(failures, rec.Path+": "+err.Error()) + } + } return failures } // cutPlan is the validated set of writes, built before any of them runs. type cutPlan struct { + // records are the intent records rewritten as the cut moves a missed + // target to `next`, written first. + records []intent.TargetRewrite changelog []byte page []byte // nil when no page is written undo UndoPlan @@ -164,6 +179,7 @@ func planPage(root string, plan *cutPlan) error { // returns the undo that reverses a completed plan. func execute(ops fileOps, root string, plan cutPlan) (UndoPlan, error) { done := plan.undo + done.records = nil done.archived = "" done.pageWritten = false done.changelogDone = false @@ -176,6 +192,12 @@ func execute(ops fileOps, root string, plan cutPlan) (UndoPlan, error) { return UndoPlan{}, fmt.Errorf("%s\n the steps already taken were rolled back; the tree is as it was", msg) } + for _, rec := range plan.records { + if err := ops.replace(rec.Path, rec.After); err != nil { + return fail(rec.Path, err) + } + done.records = append(done.records, rec) + } if plan.page != nil && plan.undo.archived != "" { if err := ops.createExclusive(plan.undo.archived, plan.undo.pageBefore); err != nil { // The create may have made the directory before failing. diff --git a/internal/core/site/compose.go b/internal/core/site/compose.go index cc09ab6eb..6035eda3b 100644 --- a/internal/core/site/compose.go +++ b/internal/core/site/compose.go @@ -1444,6 +1444,8 @@ func (c *composer) auditIsMet(rel string) bool { // The fence check comes first, ahead of the dated-heading test, so a fenced // heading moves no version cursor either: both failures are silent, rendering a // plausible wrong version rather than none (iss-2609090951287232). +// A cut's move note is passed over too: it names the targeted intents the +// release did NOT ship (changelog.IsTargetMoveNote). func (c *composer) releaseOf(id string) string { data, err := fsutil.ReadGuardedInRoot(c.root, "CHANGELOG.md", changelog.MaxChangelogBytes) if err != nil { @@ -1464,6 +1466,12 @@ func (c *composer) releaseOf(id string) string { } continue } + // A cut's move note names the targeted intents it passed without + // shipping them (itd-2609212103572513 criterion 3): it credits no + // record with the release it sits in. + if changelog.IsTargetMoveNote(line) { + continue + } if version != "" && creditsHandle(line, want) { return version } diff --git a/internal/core/site/compose_test.go b/internal/core/site/compose_test.go index f745fb6f6..c0b51f080 100644 --- a/internal/core/site/compose_test.go +++ b/internal/core/site/compose_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/intentdriven/abcd/internal/core/changelog" + "github.com/intentdriven/abcd/internal/core/launch" ) // The header and footer forge links are labelled with the forge's declared @@ -161,6 +162,42 @@ func TestReleaseOfMatchesTheHandleAtAWordBoundary(t *testing.T) { } } +// A cut that passes a targeted intent names it in the section's move note +// (itd-2609212103572513 criterion 3, ruling BS1 of 2026-09-29), and that line +// names a release the intent did NOT ship in: releaseOf passes over it, so the +// planned intent is stamped with nothing and a shipped one keeps the release +// that credits it. +func TestReleaseOfPassesOverTheTargetMoveNote(t *testing.T) { + dir := t.TempDir() + writeSourceFile(t, dir, "CHANGELOG.md", strings.Join([]string{ + "# Changelog", + "", + "## [Unreleased]", + "", + "## [0.9.0] - 2026-09-01", + "", + changelog.TargetMoveNote([]launch.TargetMove{{ID: "itd-500", From: "v0.9.0"}, {ID: "itd-199", From: "next"}}), + "", + "### Added", + "", + "- A later promise, delivered. (itd-1990)", + "", + "## [0.3.0] - 2026-05-01", + "", + "### Added", + "", + "- The promise this release delivered. (itd-199)", + "", + }, "\n")) + + c := &composer{root: mustOpenRoot(t, dir)} + for id, want := range map[string]string{"itd-500": "", "itd-199": "0.3.0", "itd-1990": "0.9.0"} { + if got := c.releaseOf(id); got != want { + t.Errorf("releaseOf(%q) = %q, want %q", id, got, want) + } + } +} + // The word boundary the pattern ends in closes the handle against a word // character, and `-` is not one — so `fix/itd-199-cleanup`, `iss-0100-*.md` and // every other branch name, file stem and run id of that shape still yields the diff --git a/internal/surface/cli/intent_target_cli_test.go b/internal/surface/cli/intent_target_cli_test.go index 0b17cd300..41680fdad 100644 --- a/internal/surface/cli/intent_target_cli_test.go +++ b/internal/surface/cli/intent_target_cli_test.go @@ -138,4 +138,16 @@ func TestLaunchPreviewAndCutListTheTargetedIntent(t *testing.T) { if !strings.Contains(string(shipped), "targeted: itd-91 targets v0.4.1, not shipped") || !strings.Contains(string(shipped), "wrote:") { t.Errorf("the written cut must list the targeted intent:\n%s", shipped) } + // Criterion 3 (ruling BS1 of 2026-09-29): the cut passed the target, so + // the same write moves it to `next` and the report says so. + if !strings.Contains(string(shipped), "moved: itd-91 targets next (targeted v0.4.1)") { + t.Errorf("the written cut must report the moved target:\n%s", shipped) + } + rec, err := os.ReadFile(filepath.Join(r.Root(), ".abcd/development/intents/planned/itd-91-targeted.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rec), "target_release: next\n") { + t.Errorf("the cut must rewrite the missed target to next:\n%s", rec) + } } diff --git a/internal/surface/cli/ship.go b/internal/surface/cli/ship.go index d1de3aad5..d3219aee3 100644 --- a/internal/surface/cli/ship.go +++ b/internal/surface/cli/ship.go @@ -765,6 +765,11 @@ func renderIngest(w io.Writer, res shipResult) { fmt.Fprintf(w, " wrote: %s\n", res.Path) fmt.Fprintf(w, " %s\n", res.Heading) fmt.Fprintf(w, " %d line(s), citing %s\n", res.Lines, termsafe.Sanitize(strings.Join(res.Cited, ", "))) + // Every target the cut passed without shipping it, moved to `next` in the + // same write and named in the section (itd-2609212103572513 criterion 3). + for _, m := range res.Moved { + fmt.Fprintf(w, " moved: %s targets next (targeted %s)\n", termsafe.Sanitize(m.ID), termsafe.Sanitize(m.From)) + } if res.Page.Written { fmt.Fprintf(w, " page: %s\n", res.Page.Path) fmt.Fprintf(w, " %s\n", termsafe.Sanitize(res.Page.Heading)) From 7bcea7e10339beba22f3c390658e7c01db21e906 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:45:18 +0100 Subject: [PATCH 08/69] feat(status): a status block row shows the release its intent targets Criterion 4 of itd-2609212103572513: given the status block, when a targeted intent is listed, then its row shows the target. - statusblock.Row carries `target_release` for a planned intent, in Now, Next and Later alike; a draft carrying one by hand shows none, since the cut reads targets off planned intents alone. - The text board adds `target ` in a Now or Next row's brackets, after its lane state or `next up`. Later stays a count (ruling BV1), so a Later row's target is in --json and on the site. - The site's Status page adds the target after what places a row, under a new `status.target` label in ui.json (site-src and the setup source), following the block's own labels. Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/08-abcd.md | 8 ++- .../development/brief/04-surfaces/22-site.md | 3 +- commands/abcd.md | 4 +- internal/core/site/fixture_test.go | 2 +- internal/core/site/setupsrc/ui.json | 1 + internal/core/site/status.go | 22 +++++++- internal/core/site/status_test.go | 28 ++++++++++ internal/core/site/ui.go | 2 + internal/core/statusblock/statusblock.go | 17 ++++-- internal/core/statusblock/statusblock_test.go | 55 +++++++++++++++++++ internal/surface/cli/board_status.go | 23 +++++++- internal/surface/cli/board_status_test.go | 32 +++++++++++ site-src/ui.json | 1 + 13 files changed, 182 insertions(+), 16 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index 7f7a4dd23..e5042cd4d 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -239,12 +239,14 @@ reads no other checkout, so an intent a peer holds can still be the head. The block's `order` field names that order (`pick`). The text render is a `status:` heading with the three counts, then `Now:` and `Next:`, one line per intent: its id, its title, and in brackets its lane state -or `next up`; then `Later: N intents`, Later as a count alone (ruling BV1 of +or `next up`, then `target ` when the intent names the release it must +land by (itd-2609212103572513 criterion 4); then `Later: N intents`, Later as a count alone (ruling BV1 of 2026-09-29), its rows left to the JSON and the site's Status page. The JSON carries a `status` object with `now`, `next` and `later` in full, each row `id`, `title`, `bucket`, and -`next_up`, `lane` (`run`, `lane`, `step`, `awaiting`) or `failing_checks` when -they apply, and `order`. The block is present in a repository abcd manages and +`next_up`, `lane` (`run`, `lane`, `step`, `awaiting`), `failing_checks` or +`target_release` (a planned intent's target, `next` or `vX.Y.Z`) when they +apply, and `order`. The block is present in a repository abcd manages and absent elsewhere, and a record that cannot be read omits it with the reason on stderr. The read is `internal/core/statusblock`, the one the site's Status page renders too ([`22-site.md`](22-site.md#the-page-set)); the state file is diff --git a/.abcd/development/brief/04-surfaces/22-site.md b/.abcd/development/brief/04-surfaces/22-site.md index dad329c4d..f6e1de187 100644 --- a/.abcd/development/brief/04-surfaces/22-site.md +++ b/.abcd/development/brief/04-surfaces/22-site.md @@ -103,7 +103,8 @@ The status page is the record health page, `/record/health/`. It opens with the Now / Next / Later block the bare `abcd` board carries ([`08-abcd.md`](08-abcd.md)), rendered from the same read (`internal/core/statusblock`): three panels, each row an intent's id linked to -its record page, its title, and what places it there, with Next and the head +its record page, its title, what places it there, and the release it targets +when it names one (the `status.target` label), with Next and the head in the pick order the board reads them in. The site build reads the implement loop's state file for Now's lane rows through the reader its front door hands it, the loop's own, and a build with no state file, as a release diff --git a/commands/abcd.md b/commands/abcd.md index 9d04bfd07..58d47d20d 100644 --- a/commands/abcd.md +++ b/commands/abcd.md @@ -84,7 +84,9 @@ intent a build run has in a lane (each row's `lane` names the run, the lane, its next step and the role it waits on), then the intent marked `next_up`; Next is every planned intent the readiness gate reports READY; Later is every planned intent the gate refuses, its `failing_checks` named, then the drafts. -An intent in a lane is listed under Now only, never also under Next or Later. +A planned intent that names the release it must land by carries it as +`target_release` on its row, in any list, and its text line shows `target +` in the brackets. An intent in a lane is listed under Now only, never also under Next or Later. Next and the `next_up` intent are read in `abcd build next`'s pick order (`order` is `pick`): the readiest first by the pick's score, the oldest among equals, and the head passes over an intent that diff --git a/internal/core/site/fixture_test.go b/internal/core/site/fixture_test.go index b0310d9e4..af8c6bc6a 100644 --- a/internal/core/site/fixture_test.go +++ b/internal/core/site/fixture_test.go @@ -323,7 +323,7 @@ func (f *fixture) writeSources() { "multi_trailer": "Commits declaring more than one model", "not_a_defect": "Not a fault: it is why the trailer count and the commit count differ", "supersedes_lead": "Not a fault: the record on the left replaced the one on the right", "clean": "Nothing to report", "suggestion": "Suggested"}, - "status": {"now": "Now", "next": "Next", "later": "Later", "next_up": "next up", "fails": "fails", "draft": "draft", "none": "None", + "status": {"now": "Now", "next": "Next", "later": "Later", "next_up": "next up", "fails": "fails", "draft": "draft", "target": "target", "none": "None", "order_record_id": "READY intents read oldest id first"}, "panels": {"latest": "Latest decisions", "health": "Record health", "unresolved": "unresolved", "baseline": "baseline", "isolated": "isolated"}, diff --git a/internal/core/site/setupsrc/ui.json b/internal/core/site/setupsrc/ui.json index 6f64e3f7b..c7c80329a 100644 --- a/internal/core/site/setupsrc/ui.json +++ b/internal/core/site/setupsrc/ui.json @@ -120,6 +120,7 @@ "next_up": "next up", "fails": "fails", "draft": "draft", + "target": "target", "none": "None", "order_record_id": "READY intents read oldest id first" } diff --git a/internal/core/site/status.go b/internal/core/site/status.go index fe7165b6f..c70d4ccac 100644 --- a/internal/core/site/status.go +++ b/internal/core/site/status.go @@ -9,7 +9,8 @@ package site // door passes the implement loop's; a nil one reads as an absent state file), // so the page and the board cannot disagree about what is Now, Next or Later. // Every word the block adds is an id, a title, a lane state from the state file, -// a readiness check's name, or an interface label from `site-src/ui.json`. +// a readiness check's name, a target release from the record, or an interface +// label from `site-src/ui.json`. import ( "strconv" @@ -62,9 +63,24 @@ func (e *explorer) statusRows(rows []statusblock.Row) string { return b.String() } -// statusTag is what places a row, as escaped HTML: the lane and its next step, -// the next-up mark, the gating checks a refused intent fails, or the draft mark. +// statusTag is what places a row, as escaped HTML — the lane and its next +// step, the next-up mark, the gating checks a refused intent fails, or the +// draft mark — then the release the intent targets, when it names one +// (itd-2609212103572513 criterion 4). func (e *explorer) statusTag(r statusblock.Row) string { + place := e.statusPlace(r) + if r.Target == "" { + return place + } + target := escapeText(e.c.ui.Status.Target) + ` ` + escapeText(r.Target) + if place == "" { + return target + } + return place + ` · ` + target +} + +// statusPlace is what places a row, as escaped HTML, or nothing. +func (e *explorer) statusPlace(r statusblock.Row) string { ui := e.c.ui.Status switch { case r.Lane != nil: diff --git a/internal/core/site/status_test.go b/internal/core/site/status_test.go index e31f96a7f..0c9a75cec 100644 --- a/internal/core/site/status_test.go +++ b/internal/core/site/status_test.go @@ -127,3 +127,31 @@ func TestStatusPageCarriesNoOrderNote(t *testing.T) { t.Errorf("the block does not open on its panels:\n%s", block) } } + +// TestStatusSectionShowsEachRowsTarget is itd-2609212103572513 criterion 4 on +// the site's Status page: a row whose intent names a release shows the target +// after what places it there, under the ui.json label, in Now, Next and +// Later alike; a row with none shows none. +func TestStatusSectionShowsEachRowsTarget(t *testing.T) { + f := newFixture(t) + ui, err := LoadUI(f.Root(), "site-src/ui.json") + if err != nil { + t.Fatal(err) + } + e := &explorer{c: &composer{ui: ui}, status: &statusblock.Block{ + Now: []statusblock.Row{{ID: "itd-5", Title: "Five", Bucket: "planned", Target: "next", NextUp: true}}, + Next: []statusblock.Row{{ID: "itd-5", Title: "Five", Bucket: "planned", Target: "next"}, {ID: "itd-4", Title: "Four", Bucket: "planned"}}, + Later: []statusblock.Row{{ID: "itd-6", Title: "Six", Bucket: "planned", Target: "v0.11.0", Failing: []string{"spec_link"}}}, + }} + got := e.statusSection() + for _, want := range []string{ + `itd-5Fivenext up · target next`, + `itd-5Fivetarget next`, + `itd-4Four`, + `itd-6Sixfails spec_link · target v0.11.0`, + } { + if !strings.Contains(got, want) { + t.Errorf("the block lacks\n%s\nin\n%s", want, got) + } + } +} diff --git a/internal/core/site/ui.go b/internal/core/site/ui.go index 830d49414..1913464d4 100644 --- a/internal/core/site/ui.go +++ b/internal/core/site/ui.go @@ -168,6 +168,8 @@ type StatusUI struct { Fails string `json:"fails"` // Draft marks a Later row that is a draft. Draft string `json:"draft"` + // Target leads the release a row's intent targets (itd-2609212103572513). + Target string `json:"target"` // None stands in an empty list. None string `json:"none"` // OrderRecordID is the note a block read oldest id first carried. The diff --git a/internal/core/statusblock/statusblock.go b/internal/core/statusblock/statusblock.go index 511a555c6..8533a0c41 100644 --- a/internal/core/statusblock/statusblock.go +++ b/internal/core/statusblock/statusblock.go @@ -58,13 +58,18 @@ type Block struct { Order string `json:"order"` } -// Row is one intent on the block: its id and title, the shelf it sits on, and -// what places it where it is. A field another placement needs (a target -// release, a score) joins here, omitted when empty. +// Row is one intent on the block: its id and title, the shelf it sits on, the +// release it targets, and what places it where it is. A field another +// placement needs (a score) joins here, omitted when empty. type Row struct { ID string `json:"id"` Title string `json:"title"` Bucket string `json:"bucket"` + // Target is the release a planned intent names as the one it must land by + // (`target_release`: `next` or vX.Y.Z, itd-2609212103572513 criterion 4), + // empty when it names none. A draft shows none: a target is a promise about + // planned work, and the cut reads it off planned intents alone. + Target string `json:"target_release,omitempty"` // NextUp marks the pick order's head on Now. NextUp bool `json:"next_up,omitempty"` // Lane is the lane state of a Now row the state file shows. @@ -121,7 +126,11 @@ func Read(repoRoot string, lanes LaneReader) (Block, error) { if err != nil { return Row{}, err } - return Row{ID: it.ID, Title: l.Title, Bucket: it.Bucket}, nil + r := Row{ID: it.ID, Title: l.Title, Bucket: it.Bucket} + if it.Bucket == intent.BucketPlanned { + r.Target = it.TargetRelease + } + return r, nil } // The state file is read first: an intent it shows in a lane is listed diff --git a/internal/core/statusblock/statusblock_test.go b/internal/core/statusblock/statusblock_test.go index ffcf22f11..e5f7a38fe 100644 --- a/internal/core/statusblock/statusblock_test.go +++ b/internal/core/statusblock/statusblock_test.go @@ -426,3 +426,58 @@ func TestTheHeadPassesOverWhatTheBuildRefusesFromTheRecord(t *testing.T) { }) } } + +// TestARowShowsItsTarget is itd-2609212103572513 criterion 4: given the +// status block, when a targeted intent is listed, then its row shows the +// target — in Now (a lane row and the head), in Next and in Later alike, and in +// the JSON as `target_release`. A row with no target carries none, and a draft +// carrying one by hand shows none: a target is a promise about planned work, +// and the cut reads it off planned intents alone. +func TestARowShowsItsTarget(t *testing.T) { + root := store(t) + w := func(rel, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(rel)), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + const in = ".abcd/development/intents/" + w(in+"planned/itd-2609010000000001-late.md", readyIntent("itd-2609010000000001", "The stamped one", "spc-2609010000000011", "target_release: v0.11.0\n")) + w(in+"planned/itd-7-seven.md", readyIntent("itd-7", "The seventh", "spc-17", "target_release: next\n")) + w(in+"planned/itd-5-held.md", readyIntent("itd-5", "The held one", "spc-15", "held: \"awaiting a ruling\"\ntarget_release: v0.12.0\n")) + w(in+"planned/itd-8-unlinked.md", readyIntent("itd-8", "The unlinked one", "null", "target_release: next\n")) + w(in+"drafts/itd-3-old.md", strings.Replace(draft("itd-3", "An old idea"), "kind: standalone\n", "kind: standalone\ntarget_release: next\n", 1)) + + b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lane: Lane{Run: "run-1", Step: "implement"}})) + if err != nil { + t.Fatal(err) + } + targets := map[string]string{} + for _, list := range [][]Row{b.Now, b.Next, b.Later} { + for _, r := range list { + targets[r.ID] = targets[r.ID] + "|" + r.Target + } + } + for id, want := range map[string]string{ + "itd-2609010000000001": "|v0.11.0", // Now, in a lane + "itd-7": "|next|next", // Now as the head, and Next + "itd-5": "|v0.12.0", // Next + "itd-8": "|next", // Later, not READY + "itd-3": "|", // a draft shows none + "itd-2609020000000002": "|", // no target, none shown + } { + if targets[id] != want { + t.Errorf("%s rows carry targets %q, want %q", id, targets[id], want) + } + } + raw, err := json.Marshal(b) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"id":"itd-8","title":"The unlinked one","bucket":"planned","target_release":"next"`) { + t.Errorf("--json must carry each row's target as target_release:\n%s", raw) + } + if strings.Count(string(raw), `"target_release"`) != 5 { + t.Errorf("a row with no target carries no target_release key:\n%s", raw) + } +} diff --git a/internal/surface/cli/board_status.go b/internal/surface/cli/board_status.go index 05eed3097..4fb9a40fb 100644 --- a/internal/surface/cli/board_status.go +++ b/internal/surface/cli/board_status.go @@ -37,7 +37,8 @@ func boardStatus(cwd string, stderr io.Writer) *statusblock.Block { // renderBoardStatus writes the block: a heading with the three counts, then // Now and Next, one row per intent (Next in the pick order) — its id, its -// title, and in brackets what places it there (its lane state or "next up") — +// title, and in brackets what places it there (its lane state or "next up") +// and the release it targets — // then Later as a count of intents alone (ruling BV1 of 2026-09-29): its rows, // with the gating checks each fails, are in --json and on the site's Status // page. @@ -70,9 +71,25 @@ func renderBoardStatus(w io.Writer, b *statusblock.Block) { fmt.Fprintf(w, " Later: %d %s\n", len(b.Later), noun) } -// statusRowTag is what places a Now or Next row where it is, in words: its -// lane state or "next up"; a READY intent in no lane carries none. +// statusRowTag is what places a Now or Next row where it is, in words — its +// lane state or "next up"; a READY intent in no lane carries none — then the +// release the intent targets, when it names one (itd-2609212103572513 +// criterion 4). func statusRowTag(r statusblock.Row) string { + place := statusRowPlace(r) + if r.Target == "" { + return place + } + target := "target " + termsafe.Sanitize(r.Target) + if place == "" { + return target + } + return place + "; " + target +} + +// statusRowPlace is what places a Now or Next row where it is: its lane state +// or "next up", or nothing. +func statusRowPlace(r statusblock.Row) string { switch { case r.Lane != nil: l := r.Lane diff --git a/internal/surface/cli/board_status_test.go b/internal/surface/cli/board_status_test.go index 4b6e4a500..dba327ef2 100644 --- a/internal/surface/cli/board_status_test.go +++ b/internal/surface/cli/board_status_test.go @@ -223,3 +223,35 @@ func TestBoardRendersLaterAsACount(t *testing.T) { } } } + +// TestBoardRowShowsItsTarget is itd-2609212103572513 criterion 4 at the text +// board: a Now or Next row whose intent names a release shows the target in +// its brackets, after what places it there; a row with none shows none. +// Later is a count on the text board (ruling BV1), so a Later row's target is +// in --json and on the site's Status page. +func TestBoardRowShowsItsTarget(t *testing.T) { + var buf bytes.Buffer + renderBoardStatus(&buf, &statusblock.Block{ + Now: []statusblock.Row{ + {ID: "itd-7", Title: "In a lane", Bucket: "planned", Target: "v0.11.0", + Lane: &statusblock.Lane{Run: "run-1", Lane: "lane-1", Step: "implement"}}, + {ID: "itd-5", Title: "The head", Bucket: "planned", Target: "next", NextUp: true}, + }, + Next: []statusblock.Row{ + {ID: "itd-5", Title: "The head", Bucket: "planned", Target: "next"}, + {ID: "itd-6", Title: "Untargeted", Bucket: "planned"}, + }, + Later: []statusblock.Row{}, + }) + got := buf.String() + for _, want := range []string{ + " itd-7 In a lane [lane-1: implement (run-1); target v0.11.0]\n", + " itd-5 The head [next up; target next]\n", + " itd-5 The head [target next]\n", + " itd-6 Untargeted\n", + } { + if !strings.Contains(got, want) { + t.Errorf("the board must carry %q:\n%s", want, got) + } + } +} diff --git a/site-src/ui.json b/site-src/ui.json index 6f64e3f7b..c7c80329a 100644 --- a/site-src/ui.json +++ b/site-src/ui.json @@ -120,6 +120,7 @@ "next_up": "next up", "fails": "fails", "draft": "draft", + "target": "target", "none": "None", "order_record_id": "READY intents read oldest id first" } From 279f5241f1425638a54a9a36a94b9f0192441a06 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:45:36 +0100 Subject: [PATCH 09/69] chore(record): ship itd-2609212103572513 with criterion 3 worded to ruling BS1 The product thinker ruled on 2026-09-29 (BS1) that a target the cut passes becomes `next`, whatever the following release is numbered. The intent's criterion 3 and its in-scope line said "rewritten to the next version"; both are worded to the ruling, and decision 4 records it with its reason (the version a cut derives is the one it cuts, so a number would name a release already out). Spec scope 3 is worded the same way. Every criterion is met at this head: 1 and 2 by the earlier target lane (intent target, plan --target, the record_schema leg, the preview and cut lists), 3 and 4 by the two commits before this one. Closing spc-2609212138243443 (impact additive, as the intent declares) ships the intent; its fidelity review is owed (receipt rcp-47e25ab4498e). Delivers: itd-2609212103572513 Assisted-by: Claude:claude-opus-5-5 --- ...names-the-release-it-must-land-by-and-the-cut-says.md | 9 ++++++--- ...names-the-release-it-must-land-by-and-the-cut-says.md | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) rename .abcd/development/intents/{planned => shipped}/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md (77%) rename .abcd/development/specs/{open => closed}/spc-2609212138243443-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md (85%) diff --git a/.abcd/development/intents/planned/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md b/.abcd/development/intents/shipped/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md similarity index 77% rename from .abcd/development/intents/planned/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md rename to .abcd/development/intents/shipped/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md index 5b997c5e8..4ea923160 100644 --- a/.abcd/development/intents/planned/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md +++ b/.abcd/development/intents/shipped/itd-2609212103572513-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md @@ -38,7 +38,7 @@ None stated. - **The field**: `target_release: vX.Y.Z` (or `next`) on a planned intent, validated as a version, written by `intent plan --target` or `intent target `; the record lint refuses it on a shipped or superseded intent. - **The report**: `launch --dry-run` and the cut list every targeted intent not yet shipped, in text, in the receipt and in `--json`; the cut proceeds. -- **The move**: at the cut, each unshipped target is rewritten to the next version in the change that rolls the changelog, and the changelog names the move. +- **The move**: at the cut, each unshipped target the cut passes becomes `next` (whatever the following release is numbered) in the change that rolls the changelog, and the changelog names the move. - **The board**: the status block marks a targeted intent with its target in Next and Later. ## What's Out of Scope @@ -54,6 +54,7 @@ Ruled by the product thinker on 2026-09-21, in the interview that filed and plan 1. Report, never refuse (adr-2609212115255771, decision 3). 2. The target moves forward at the cut, so the field never goes stale. 3. The field is optional and lives on the intent alone. +4. A target the cut passes becomes `next`, whatever the following release is numbered, never a version number: the product thinker's ruling BS1 of 2026-09-29 (the dated entry of that day in `.abcd/work/DECISIONS.md`). The version a cut derives is the one it cuts, so a number written at the cut would name a release already out, which decision 2 exists to prevent. A target the cut passes is `next` (it named this release) or a tag at or below the one cut; a tag above it is still ahead and stays. Criterion 3 is worded to the ruling. ## Open Questions @@ -63,12 +64,14 @@ _None open._ - **Given** a planned intent, **when** `intent target v0.11.0` runs, **then** the record carries `target_release: v0.11.0`, and the same on a shipped or superseded intent is refused by the verb and by the lint. - **Given** a targeted intent still planned, **when** `launch --dry-run` or the cut runs, **then** it is listed as targeted and unshipped in text, the receipt and `--json`, and the cut proceeds. -- **Given** the cut is written, **when** the changelog is rolled, **then** each unshipped target is rewritten to the next version in the same change and the changelog names the move. +- **Given** the cut is written, **when** the changelog is rolled, **then** each unshipped target the cut passes becomes `next` (whatever the following release is numbered) in the same change, and the changelog names the move. - **Given** the status block, **when** a targeted intent is listed, **then** its row shows the target. ## Audit Notes -_Empty. Populated by intent-auditor when intent moves to shipped/._ + +Fidelity review OWED (receipt rcp-47e25ab4498e). + ## Grounds diff --git a/.abcd/development/specs/open/spc-2609212138243443-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md b/.abcd/development/specs/closed/spc-2609212138243443-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md similarity index 85% rename from .abcd/development/specs/open/spc-2609212138243443-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md rename to .abcd/development/specs/closed/spc-2609212138243443-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md index 496c2e865..62c42952a 100644 --- a/.abcd/development/specs/open/spc-2609212138243443-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md +++ b/.abcd/development/specs/closed/spc-2609212138243443-an-intent-names-the-release-it-must-land-by-and-the-cut-says.md @@ -15,7 +15,7 @@ The design record for itd-2609212103572513: the `target_release` field, its verb 1. **The field and verbs**: `intent target` and `plan --target` in `internal/core/intent`, version-validated through the release package's parser; lint row in `record_schema` (criterion 1). 2. **The report**: `launch.DryRun` and `launch.Ship` read planned intents with a target and list those not in `shipped/` (criterion 2). -3. **The move**: the ship path rewrites each listed target to the derived next version in the receipts commit and appends a changelog line (criterion 3). +3. **The move**: the ship path rewrites each listed target the cut passes to `next` (ruling BS1 of 2026-09-29, the intent's decision 4) in the write that rolls the changelog, and the dated section names the move in one line (criterion 3). 4. **The board**: the status block reads the field (criterion 4). ## Out of scope From 6afb514f98291927441777960235e3ed841cd171 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:50:28 +0100 Subject: [PATCH 10/69] feat(drain): read the drained repository's own eligibility record, name every loosened floor, hand back what waits on a person The drain no longer applies a rule compiled into the binary. It reads the drained repository's own decision record and refuses a repository without one, as the product thinker ruled on 2026-09-29. BX2, verbatim: "the PROJECT MUST HOLD the eligibility decision in its own record (e.g. added at setup); drain refuses there until it does". H11, verbatim: "MAY LOOSEN abcd's floors (a project may let drain take major/critical and security issues). NOTE for the lane: make a loosened floor loud (drain --dry-run and the drain start name every floor the project loosened), and keep abcd's own repository at the stricter default." The record: the one accepted ADR in .abcd/development/decisions/adrs/ whose frontmatter carries drain_categories (inline list, a subset of the fixable set), drain_severities (inline list of severities), drain_security (handback | take) and drain_remedy (required, its only value: the remedy is the brief a lane works from). The new leaf package core/drainrule reads it inside an os.Root at the checkout (a store symlinked out of the tree is refused), measures it against abcd's bundled strict baseline and names each loosened floor ("severity major", "severity critical", "security"). No record, only a proposed or superseded one, two accepted ones, or a missing, misspelt, repeated or mis-valued field all refuse (exit 2, nothing written) with no fallback to the baseline or to a looser rule. Widening the categories is refused as a decision by kind, which H11 does not name. abcd's own adr-2609291342092738 now carries the baseline as the four fields, and TestAbcdsOwnDrainRuleIsTheStrictBaseline fails if it loosens anything. Loud: the dry run prints a LOOSENED block (or one line saying nothing is loosened), both output modes warn on stderr, --json carries `loosened` and the record's `rule`, and the start's refusal names every floor. The waiting records (50 of 54 dry-run-eligible records on the remedies branch wait on a ruling): BOTH shapes are handed back, each its own rule, whatever the repository's record says. A remedy opening "Waits on" (case-folded) is `waits-on-ruling`, because taking it would make the ruling it waits on; a record whose deferred_after names the current anchor tag is `deferred`, because a person carried it past this release. They are asked after the category and severity hand-backs, the ruling before the deferral since it names the decision owed. The release tags are read only when an open record carries a deferral, and a failure to read them refuses the plan rather than letting a live deferral through. The brief chapter states the trust boundary: the record is a repository-authored file deciding what an unattended agent may do, so a contributor's pull request can loosen it; it is committed history reviewed like code, a loosening is loud on every run, and abcd's own repository keeps the baseline under a test. Partial of itd-82; its spec stays open (the host judgement, the lane, the hand-back writes and the pace are still owed). Assisted-by: Claude:claude-opus-5-5 --- .../brief/02-constraints/03-invariants.md | 2 +- .../development/brief/04-surfaces/35-drain.md | 129 ++++-- ...issue-alone-only-when-its-fields-say-it.md | 41 +- .abcd/development/release/surface.json | 2 +- commands/drain.md | 103 +++-- docs/reference/cli/commands.md | 29 +- internal/core/capture/capture.go | 4 + internal/core/capture/eligible.go | 207 +++++---- internal/core/capture/eligible_test.go | 263 ++++++++++-- internal/core/capture/validate.go | 1 + internal/core/drainrule/drainrule.go | 400 ++++++++++++++++++ internal/core/drainrule/drainrule_test.go | 236 +++++++++++ internal/core/report/inbox_test.go | 12 + internal/core/surface/sentences.go | 4 +- internal/surface/cli/barerender.go | 4 +- .../cli/capture_remedy_required_test.go | 3 +- internal/surface/cli/drain.go | 60 ++- internal/surface/cli/drain_surface_test.go | 121 +++++- 18 files changed, 1411 insertions(+), 210 deletions(-) create mode 100644 internal/core/drainrule/drainrule.go create mode 100644 internal/core/drainrule/drainrule_test.go diff --git a/.abcd/development/brief/02-constraints/03-invariants.md b/.abcd/development/brief/02-constraints/03-invariants.md index 3e6243013..d7a928c18 100644 --- a/.abcd/development/brief/02-constraints/03-invariants.md +++ b/.abcd/development/brief/02-constraints/03-invariants.md @@ -48,4 +48,4 @@ The following are non-negotiable invariants — any architectural choice that vi 18. **Shape claims in the brief's surface chapters are derived, never hand-authored** — every flag and sub-verb the chapters under `04-surfaces/` state comes from the command tree, through one generated appendix at the end of each chapter under `04-surfaces/`, composed from the walk that builds the compatibility snapshot, with one exception: a chapter's `## Sub-verbs` table (adr-40 §6) and its standard note are hand-written, and `surface_coverage` checks them against the snapshot in both directions. A chapter whose command the tree does not register carries the same block saying there is no shipped surface. The prose above the appendix states no flag the tree registers and no sub-verb written as an invocation, and says why a surface exists, what it refuses and which trade was made. The appendix carries flags and sub-verbs only: exit codes, output fields and behavioural claims stay prose and review-grain until the binary records them where a generator can read. Per [adr-2609231028044006](../../decisions/adrs/2609231028044006-surface-chapter-shape-claims-are-derived-never-hand-authored.md), delivering itd-147. Held by `TestSurfaceAppendicesMatchCommandTree` and `TestSurfaceChapterProseStatesNoShape` (`internal/surface/cli/brief_appendix_test.go`), which run in `go test ./...`. `surface_coverage` stays the row-level presence check over the surfaces index and each chapter's sub-verb table, and says so in every finding. The invariant holds those chapters only: a shape claim elsewhere in the record is not checked by it. How a chapter is added is in [`04-surfaces/README.md` § The generated appendix](../04-surfaces/README.md#the-generated-appendix). -19. **A machine takes an issue alone only when its fields say it needs no decision** — an unattended drain takes an open issue only when no record in its `blocked_by` is still open, its category is in the fixable set (`bug`, `documentation`, `drift`, `inconsistency`, `tech-debt`, `ux`), its severity is `nitpick` or `minor`, and it carries a `remedy:` other than `none (filed automatically)`, the value an automatic filer writes when it has no fix (every new issue carries a remedy; a record filed before that carries none and is listed as ineligible); `security` is always a person's, and every other open issue is handed back, listed as ineligible or skipped by the rule that excluded it, each with exactly one disposition. A host judgement over the remedy may only hand an issue back, never let one through, and the classification is re-derived every run rather than written onto the issue. The drain refuses to start without the record of the rule. Per [adr-2609291342092738](../../decisions/adrs/2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md), from itd-82 decision 4; the rule is `eligibility` in `internal/core/capture/eligible.go`, reached through `capture.PlanDrain`, and `TestTheEligibilityRuleIsRecordedAndAccepted` holds the record the binary names to an accepted record this invariant cites. +19. **A machine takes an issue alone only when its fields say it needs no decision** — an unattended drain takes an open issue only under the drained repository's own rule, read from an accepted decision record in its own store carrying the four `drain_` fields, and refuses a repository without one, or with a partial, malformed or ambiguous one, never falling back to a looser or a stricter rule. Under that rule it takes an issue only when no record in its `blocked_by` is still open, its category and severity are ones the rule takes, it carries a `remedy:` other than `none (filed automatically)`, the value an automatic filer writes when it has no fix, and its remedy does not open "Waits on" nor is its deferral live at the current anchor tag (every new issue carries a remedy; a record filed before that carries none and is listed as ineligible); every other open issue is handed back, listed as ineligible or skipped by the rule that excluded it, each with exactly one disposition. abcd's strict baseline takes the fixable set (`bug`, `documentation`, `drift`, `inconsistency`, `tech-debt`, `ux`) at `nitpick` or `minor` and hands every `security` issue to a person; a repository's record may narrow it, and may loosen it to `major`, `critical` or `security`, and every floor loosened is named by the dry run and at the start. A host judgement over the remedy may only hand an issue back, never let one through, and the classification is re-derived every run rather than written onto the issue. abcd's own repository states the baseline in [adr-2609291342092738](../../decisions/adrs/2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md), from itd-82 decision 4 and the product thinker's rulings BX2 and H11; the record is read by `drainrule.Load` in `internal/core/drainrule/drainrule.go`, the rule is `eligibility` in `internal/core/capture/eligible.go`, reached through `capture.PlanDrain`, and `TestAbcdsOwnDrainRuleIsTheStrictBaseline` holds abcd's own record to the baseline and to this citation. diff --git a/.abcd/development/brief/04-surfaces/35-drain.md b/.abcd/development/brief/04-surfaces/35-drain.md index 695596df9..12443f13a 100644 --- a/.abcd/development/brief/04-surfaces/35-drain.md +++ b/.abcd/development/brief/04-surfaces/35-drain.md @@ -24,20 +24,65 @@ issue, is not built, so the bare verb refuses to start and says so. ## The rule -The rule is a recorded decision, +Which issues a machine may take alone is the drained repository's own decision +(the product thinker's ruling BX2 of 2026-09-29: "the PROJECT MUST HOLD the +eligibility decision in its own record (e.g. added at setup); drain refuses there +until it does"). The rule is read from an accepted decision record in the +repository's own store, `.abcd/development/decisions/adrs/`, whose frontmatter +carries four fields: + +```yaml +drain_categories: [tech-debt, documentation, inconsistency, drift, bug, ux] +drain_severities: [nitpick, minor] +drain_security: handback +drain_remedy: required +``` + +Those values are abcd's strict baseline, bundled in the binary as the measure a +repository's record is judged against. abcd's own repository states exactly the +baseline in [adr-2609291342092738](../../decisions/adrs/2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md), -and invariant 19 in -[`02-constraints/03-invariants.md`](../02-constraints/03-invariants.md). It reads -the record's fields and nothing else: nothing open in `blocked_by`, a category -in the fixable set, severity `nitpick` or `minor`, and a `remedy:` other than -`none (filed automatically)`, the value an automatic filer writes when it has -no fix. Every open -issue receives exactly one disposition, from the first rule that excludes it: -skipped when blocked, handed back for `security`, for a category outside the -fixable set or for a severity above `minor`, ineligible without a remedy or -with the automatic filers' value until a person writes one, and -unreadable when the ledger reader refuses the record. A record written before -the field existed reads its `suggested_fix:` as its remedy. +held there by invariant 19 in +[`02-constraints/03-invariants.md`](../02-constraints/03-invariants.md) and a test. +The setup verb offers the baseline to a repository without a record and +writes it only on the person's yes (see [`01-ahoy.md`](01-ahoy.md)). + +A repository's record may narrow the fixable set and the severities. It may +loosen abcd's floors (ruling H11 of 2026-09-29: "MAY LOOSEN abcd's floors (a +project may let drain take major/critical and security issues)"): listing +`major` or `critical` in `drain_severities`, or setting `drain_security: take`. +It may not widen `drain_categories` past the fixable set, since the other +categories are decisions by kind, and `drain_remedy` has the one value +`required`, since the remedy is the brief a lane works from. A record that +misses a field, misspells one, states one twice, lists a value the field does +not take, or writes a list as anything but an inline `[a, b]` refuses; so does a +store with two accepted records carrying the fields. None of these falls back +to the baseline or to a looser rule. + +The rule reads each issue's fields and nothing else: nothing open in +`blocked_by`, a category the rule takes, a severity it takes, and a `remedy:` +other than `none (filed automatically)`, the value an automatic filer writes +when it has no fix. Every open issue receives exactly one disposition, from the +first rule that excludes it: skipped when blocked; handed back for `security` +(unless the record takes it), for a category outside the rule's set, for a +severity outside it, for a remedy that waits on a ruling, and for a deferral +that is live; ineligible without a remedy or with the automatic filers' value until +a person writes one; and unreadable when the ledger reader refuses the record. A +record written before the field existed reads its `suggested_fix:` as its remedy. + +Two hand-backs hold whatever the repository's record says, because each marks a +decision a person still owes. A remedy that opens "Waits on" (the shape a remedy +takes when its fix waits on an unanswered ruling, compared case-folded) is +handed back under `waits-on-ruling`: taking it would make the ruling. A record +whose `deferred_after` names the checkout's current anchor tag, the newest +release tag, is handed back under `deferred`: a person carried it past this +release. A deferral past an earlier tag has lapsed and holds nothing back. A +record carrying both is named for the ruling, which says which decision is +owed. +The deferral verb writes a deferral only onto a `major` or `critical` record, but +a hand-written one on a lighter record is read the same way. The release tags are +read only when an open record carries a deferral, and a failure to read them +refuses the dry run rather than letting a live deferral through. The fields are the rule because a model's judgement of its own ambiguity is unreliable, and the failure runs one way: a machine that decides a thing needs @@ -47,20 +92,56 @@ and the dry run says so beside every eligible issue. ## The order -Eligible issues are taken by category, `tech-debt`, `documentation`, -`inconsistency`, `drift`, `bug`, `ux` (clean-ups and text first, on the evidence -that they merge most often), then `nitpick` before `minor`, then oldest first. -The dry run states the rule and lists the eligible issues first in that order, -then every other open issue by id. +Eligible issues are taken by category in the order `tech-debt`, +`documentation`, `inconsistency`, `drift`, `bug`, `ux` (clean-ups and text +first, on the evidence that they merge most often), then `security` when the +record takes it; then by severity, `nitpick` before `minor` before `major` +before `critical`, as far as the record takes them; then oldest first. The +record's lists are sets: the order is abcd's. The dry run states the order and +lists the eligible issues first in it, then every other open issue by id. + +## Loud loosening + +Every floor a repository's record loosens is named, measured against the +baseline, in this order: `severity major`, `severity critical`, `security`. The +dry run's text prints a `LOOSENED` block under the rule's record, or one line +saying the rule loosens none of abcd's floors; the machine-readable payload carries the list as +`loosened` beside the record's own values under `rule`; both modes also print a +warning on stderr naming each loosened floor; and the start's refusal names +them. + +## The trust boundary + +The eligibility record is a file the drained repository authors, and it decides +what an unattended agent may change there. The threat is a contributor's pull +request that loosens it, for instance adding `major` or `drain_security: take`, +so that a later drain takes issues a person would have decided. What guards it: + +- The record is committed history in the decision store, reviewed like code, and + a change to it is a change to a decision record, which a reviewer reads as a + trust change. +- A loosening is loud: the dry run, its stderr and the start name every floor + the record loosens, so a loosened rule is never applied unseen. +- abcd's own repository keeps the strict baseline, and a test fails when its + record loosens anything or stops being the record the invariant cites. +- The reader never falls back: a missing, partial, ambiguous or malformed record + refuses, and the store is read inside the checkout, so a store or record that + is a symlink leaving it is refused rather than followed. +- The two person-owed hand-backs, a remedy waiting on a ruling and a live + deferral, hold whatever the record says. ## What it refuses -The bare verb refuses to start, exit 2, with nothing read or written: the lane -it would hand each issue to does not exist. The start check also refuses naming -the decision record it needs when the rule has none; the binary names the rule's -record, and a test holds that name to an accepted record here. A checkout that -cannot be resolved, or a ledger holding one id in two status folders, is refused -as every capture verb refuses it. +The dry run and the bare verb both refuse, exit 2 with nothing written, when +the repository holds no accepted record of the rule, naming how to add one +(the setup verb's offer, or the four fields on an accepted record); when a record +names the fields but is proposed or superseded, the refusal names it. They +refuse a malformed record, naming the record and the field, and two accepted +records, naming both. With the rule, the bare verb still refuses to start: the +lane it would hand each issue to does not exist, and the refusal names the +rule's record and every floor it loosens. A checkout that cannot be resolved, +or a ledger holding one id in two status folders, is refused as every capture +verb refuses it. ## Where this sits diff --git a/.abcd/development/decisions/adrs/2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md b/.abcd/development/decisions/adrs/2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md index 052233090..3aba94dab 100644 --- a/.abcd/development/decisions/adrs/2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md +++ b/.abcd/development/decisions/adrs/2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md @@ -8,6 +8,10 @@ superseded_by: null related_intents: [itd-82, itd-2609201916151817] related_rfcs: [] related_adrs: [adr-25, adr-27] +drain_categories: [tech-debt, documentation, inconsistency, drift, bug, ux] +drain_severities: [nitpick, minor] +drain_security: handback +drain_remedy: required --- # ADR-2609291342092738: A drain takes an issue alone only when its fields say it needs no decision @@ -61,10 +65,31 @@ no decision, and route every other open issue by the rule that excluded it. 5. **The classification is re-derived every run** and recorded with each disposition in the run's summary; nothing is written onto the issue for it (itd-82 decision 8). -6. **The drain refuses to start without this record.** The binary names this - record as the rule it applies (`capture.EligibilityRecord`), and a test - holds that name to an accepted record here, cited by the brief's - invariants. +6. **The drain refuses to start without this record.** The rule is read from + the drained repository's own decision store, never from the binary: the + four `drain_` fields in this record's frontmatter state it + (`drain_categories`, `drain_severities`, `drain_security`, + `drain_remedy`), and a repository whose store holds no accepted record + carrying them is refused by the dry run and the start alike, naming how to + add one (the product thinker's ruling BX2 of 2026-09-29, verbatim: "the + PROJECT MUST HOLD the eligibility decision in its own record (e.g. added + at setup); drain refuses there until it does"). This repository states + abcd's strict baseline, which the binary bundles as the measure a + loosening is named against, and a test holds this record to it. +7. **Another project may loosen the floors, loudly.** A project's own record + may list `major` or `critical` among its severities, or set + `drain_security: take`, and every floor it loosens is named by the dry run + and at the start (ruling H11 of 2026-09-29, verbatim: "MAY LOOSEN abcd's + floors (a project may let drain take major/critical and security issues). + NOTE for the lane: make a loosened floor loud (drain --dry-run and the + drain start name every floor the project loosened), and keep abcd's own + repository at the stricter default."). It may narrow the fixable set but + never widen it, and it cannot drop the remedy. A partial or malformed + record refuses rather than falling back to either rule. +8. **A record waiting on a person is a person's.** Whatever the record says, + an issue whose remedy opens "Waits on" (a fix that waits on an unanswered + ruling) and an issue whose deferral past the current anchor tag is live are + handed back, each naming its rule. ## Alternatives Considered @@ -90,6 +115,8 @@ no decision, and route every other open issue by the rule that excluded it. - The issue-keyed lane of `itd-2609201916151817` (decision 10) reads the same rule as the check before it starts, so the two cannot disagree about which issue is eligible. -- Owed and out of this record: whether `major` may ever be let through (an - opt-in flag is an open question on itd-82), and where a hand-back flag - lives. +- The eligibility record is a file the drained repository authors, deciding + what an unattended agent may do there. A contributor's pull request that + loosens it is a trust change: it is committed history, reviewed like code, + and a loosened floor is named on every dry run and start. +- Owed and out of this record: where a hand-back flag lives. diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 75cf865d5..87ade1037 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -1080,7 +1080,7 @@ "hidden": false, "group": "agents", "block": "agents", - "sentence": "Sort the open issues by the drain's field rule, eligible first in drain order: Writes nothing; refuses to start without --dry-run, as the run is not built.", + "sentence": "Sort open issues by this repository's own drain rule, naming each loosened floor: Writes nothing; refuses without the rule's record, or without --dry-run.", "flags": [ { "name": "dry-run", diff --git a/commands/drain.md b/commands/drain.md index 0648f154e..0187b6b6f 100644 --- a/commands/drain.md +++ b/commands/drain.md @@ -1,6 +1,6 @@ --- name: drain -description: "Sort the open issues by the drain's field rule, eligible first in drain order: Writes nothing; refuses to start without --dry-run, as the run is not built." +description: "Sort open issues by this repository's own drain rule, naming each loosened floor: Writes nothing; refuses without the rule's record, or without --dry-run." block: agents --- @@ -19,29 +19,47 @@ Run: ## The rule +Which issues a drain may take alone is **this repository's own decision**: an +accepted decision record in `.abcd/development/decisions/adrs/` whose +frontmatter carries four fields. abcd's strict baseline, which +`abcd ahoy install` offers to write, is: + +```yaml +drain_categories: [tech-debt, documentation, inconsistency, drift, bug, ux] +drain_severities: [nitpick, minor] +drain_security: handback +drain_remedy: required +``` + +A repository's record may narrow those lists, and may **loosen abcd's floors**: +list `major` or `critical` in `drain_severities`, or set `drain_security: take`. +It cannot widen the categories past that fixable set, and `drain_remedy` is +always `required`. + An open issue is **eligible** when all of these hold, read from its fields alone: - no record in its `blocked_by` is still open; -- its category is in the fixable set: `tech-debt`, `documentation`, - `inconsistency`, `drift`, `bug`, `ux`; -- its severity is `nitpick` or `minor`; +- its category is one the rule takes; +- its severity is one the rule takes; - it carries a `remedy:` (`abcd capture --remedy ""`, which every new issue carries, or `abcd capture remedy ""` onto an open one; a record carrying only the older `suggested_fix:` reads that as its remedy), - and the remedy is not `none (filed automatically)`, the value abcd's - automatic filers write when they have no fix. + the remedy is not `none (filed automatically)`, the value abcd's automatic + filers write when they have no fix, and it does not open "Waits on"; +- it carries no deferral that is live at the checkout's newest release tag. -The rule is a recorded decision, and the payload's `record` names it. The -rules are asked in a fixed order, and the first that excludes an issue decides -its one disposition: +The rules are asked in a fixed order, and the first that excludes an issue +decides its one disposition: | `outcome` | `rule` | Meaning | | --- | --- | --- | | `skipped` | `blocked` | an open record blocks it; `blockers` names them | -| `handback` | `security` | category `security` is always a person's | -| `handback` | `category` | a category outside the fixable set (`process`, `observation`, `architectural-insight`, `future-work-seed`, `lapse`) | -| `handback` | `severity` | severity `major` or `critical` | +| `handback` | `security` | category `security`, a person's unless the rule sets `drain_security: take` | +| `handback` | `category` | a category the rule does not take | +| `handback` | `severity` | a severity the rule does not take (`major` and `critical` under the baseline) | +| `handback` | `waits-on-ruling` | its remedy opens "Waits on": the fix waits on a ruling a person has not given | +| `handback` | `deferred` | its `deferred_after` names the current anchor tag: a person carried it past this release | | `ineligible` | `remedy` | no remedy (a record filed before the remedy was required), or `none (filed automatically)` from an automatic filer; ineligible until a person writes one with `abcd capture remedy`, which the reason names | | `unreadable` | `unreadable` | the ledger reader refuses the record; the reason names why | | `eligible` | `fields` | every field rule passes | @@ -53,31 +71,54 @@ a dry run, and it can only ever hand an issue back. ## The order Eligible issues come first, in the order a drain takes them: by category -`tech-debt`, `documentation`, `inconsistency`, `drift`, `bug`, `ux`; then -`nitpick` before `minor`; then oldest first. The payload's `order` states the -rule. Every other open issue follows, by id. +`tech-debt`, `documentation`, `inconsistency`, `drift`, `bug`, `ux`, then +`security` when the rule takes it; then by severity, `nitpick` first; then +oldest first. The payload's `order` states the rule. Every other open issue +follows, by id. -## The payload +## Loosened floors + +When the repository's record loosens a floor, the text output prints a +`LOOSENED` block naming each one (`severity major`, `severity critical`, +`security`), stderr carries a warning naming them in both modes, and the +payload's `loosened` lists them. **Tell the user every loosened floor first, +before anything else in the plan**: a loosened rule lets a drain take issues +abcd's baseline hands to a person, and the record that loosened it is a change +a person should have reviewed. -`dry_run` is `true`; `record` is the decision record the rule is stated in; -`order` is the ordering rule; `dispositions` holds one entry per open issue -(`id`, `path`, `severity`, `category`, `outcome`, `rule`, `reason`, and -`blockers` when skipped); `counts` totals them by outcome; `ledger` names the -checkout and branch read. +## The payload -Tell the user the counts, then the eligible issues in order, then the others -grouped by outcome with their reasons. For an `ineligible` issue, say that a -person writing a remedy with `abcd capture remedy ""` is what makes -it a candidate, and name the ones an automatic filer wrote apart, since their -reason says so. Do not act on the list: a -hand-back is a person's decision. +`dry_run` is `true`; `record` is the repository's decision record the rule is +read from; `rule` is that record's rule (`record`, `path`, `categories`, +`severities`, `security`, `remedy`, `loosened`); `loosened` lists every floor +it loosens (empty when none); `anchor` is the release tag a live deferral names, +present when an open record carries a deferral; `order` is the ordering rule; +`dispositions` holds one entry per open issue (`id`, `path`, `severity`, +`category`, `outcome`, `rule`, `reason`, and `blockers` when skipped); `counts` +totals them by outcome; `ledger` names the checkout and branch read. + +Tell the user any loosened floors, then the counts, then the eligible issues in +order, then the others grouped by outcome with their reasons. For an +`ineligible` issue, say that a person writing a remedy with +`abcd capture remedy ""` is what makes it a candidate, and name the +ones an automatic filer wrote apart, since their reason says so. For a +`waits-on-ruling` or `deferred` hand-back, say which ruling or release it waits +on. Do not act on the list: a hand-back is a person's decision. ## Refusals -- Without `--dry-run` the verb refuses to start (exit 2, nothing read or - written): the issue-keyed lane a drain hands each issue to is not built. The - refusal names what is missing and points at the dry run. It would also refuse - naming the decision record it needs, were the rule unrecorded. +- Without the repository's own record of the rule, the dry run and the bare + verb refuse (exit 2, nothing written), naming how to add it: run + `abcd ahoy install` at a terminal and accept the offer, or give an accepted + decision record the four `drain_` fields. A record carrying the fields but + proposed or superseded is named. Relay this; do not write the record for the + user. +- A malformed record (a field missing, misspelt, stated twice, or holding a + value the field does not take) refuses, naming the record and the field; two + accepted records carrying the fields refuse, naming both. +- Without `--dry-run` the verb refuses to start (exit 2, nothing written): the + issue-keyed lane a drain hands each issue to is not built. The refusal names + the rule's record, every floor it loosens, and the dry run. - Outside a checkout, or on a ledger holding one id in two status folders, it refuses (exit 2) as every capture verb does. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 199cfd126..bec5c4178 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -872,26 +872,31 @@ This is the only abcd verb that reaches the network on behalf of documentation. ### `abcd drain` -Sort the open issues by the drain's field rule, eligible first in drain order: Writes nothing; refuses to start without --dry-run, as the run is not built. +Sort open issues by this repository's own drain rule, naming each loosened floor: Writes nothing; refuses without the rule's record, or without --dry-run. **Usage:** `abcd drain [flags]` Work the open issue ledger unattended: fix the issues that need no decision, and -hand the rest back by kind. The rule for which issues need no decision is a -recorded decision, and it reads the record's fields alone: nothing open in -blocked_by; a category in the fixable set (tech-debt, documentation, -inconsistency, drift, bug, ux); severity nitpick or minor; and a remedy: field. -A security issue is always a person's. Every other open issue is handed back, -listed as ineligible, or skipped naming its blocker, by the rule that excluded it. +hand the rest back by kind. Which issues need no decision is this repository's own +recorded decision: an accepted decision record whose frontmatter carries the four +fields drain_categories, drain_severities, drain_security and drain_remedy. The +rule reads the record's fields alone: nothing open in blocked_by; a category the +rule takes; a severity it takes; and a remedy: field. abcd's strict baseline takes +tech-debt, documentation, inconsistency, drift, bug and ux at nitpick or minor, and +hands every security issue to a person. A repository's record may loosen those +floors (major, critical, security), and every floor it loosens is named. An issue +whose remedy opens "Waits on", or whose deferral past the current release tag is +live, is always handed back. Every other open issue is handed back, listed as +ineligible, or skipped naming its blocker, by the rule that excluded it. --dry-run shows every open issue's disposition, the eligible ones first in the -order a drain takes them (category tech-debt, documentation, inconsistency, -drift, bug, ux; then nitpick before minor; then oldest first), and writes -nothing. The host judgement over each eligible remedy does not run in a dry +order a drain takes them (by category, then severity, then oldest first), and +writes nothing. The host judgement over each eligible remedy does not run in a dry run; it can only ever hand an issue back. -The run itself is not built: without --dry-run the verb refuses to start, and -exits 2 with nothing read or written. +Without the repository's record, the dry run and the run both refuse (exit 2), +naming how to add it; `abcd ahoy install` offers it. The run itself is not built: +without --dry-run the verb refuses to start, and exits 2 with nothing written. **Flags:** diff --git a/internal/core/capture/capture.go b/internal/core/capture/capture.go index 9b5c17ca2..440aef259 100644 --- a/internal/core/capture/capture.go +++ b/internal/core/capture/capture.go @@ -138,6 +138,10 @@ type Issue struct { Status State `json:"status"` // derived from folder Path string `json:"path"` // repo-relative locator (iss-81) Body string `json:"body"` + // deferredAfter is the release-cut waiver's anchor tag (deferred_after), + // read for the drain's live-deferral hand-back and not surfaced: the cut + // reads the pair from the committed record itself. + deferredAfter string // BlockedByOpen is the derived subset of BlockedBy whose targets are still in // open/ (the priority projection populated by List/Status). Not a stored // field: an empty slice means the issue is unblocked. diff --git a/internal/core/capture/eligible.go b/internal/core/capture/eligible.go index 41f996944..0386d2c61 100644 --- a/internal/core/capture/eligible.go +++ b/internal/core/capture/eligible.go @@ -7,6 +7,8 @@ import ( "sort" "strings" + "github.com/intentdriven/abcd/internal/core/changelog" + "github.com/intentdriven/abcd/internal/core/drainrule" "github.com/intentdriven/abcd/internal/core/issueschema" ) @@ -14,18 +16,17 @@ import ( // and 7; spc-2609212015054359 scope 2, 6 and 9). It decides which open issues // a machine may take alone, from the record's fields and nothing else, and // gives every other open issue the disposition of the rule that excluded it. -// The rule is a recorded decision: EligibilityRecord names it. +// +// Which categories and severities a drain may take, and whether it takes +// security issues, is the drained repository's own decision, read from its own +// decision record by core/drainrule (rulings BX2 and H11); a repository without +// one is refused. Two hand-backs hold whatever that record says: a remedy that +// waits on a ruling, and a deferral that is live at the current anchor tag. // // Nothing here writes. The plan is what a drain WOULD do; the run that hands // each eligible issue to an issue-keyed lane is not built yet, and DrainStart // says so rather than pretending to run. -// EligibilityRecord is the decision record that states the rule eligibility -// applies (itd-82 decision 4). It is the record the drain refuses to start -// without; TestTheEligibilityRuleIsRecordedAndAccepted holds it to an accepted -// record in this repository's decision store, cited by the brief's invariants. -const EligibilityRecord = "adr-2609291342092738" - // DrainOutcome is the disposition a drain gives one open issue. type DrainOutcome string @@ -57,23 +58,16 @@ const ( RuleSecurity DrainRule = "security" RuleCategory DrainRule = "category" RuleSeverity DrainRule = "severity" - RuleRemedy DrainRule = "remedy" - RuleFields DrainRule = "fields" + // RuleDeferred: the record's deferral past the current anchor tag is live, + // so a person carried it past this release. + RuleDeferred DrainRule = "deferred" + // RuleWaitsOnRuling: the remedy opens "Waits on", so the fix it proposes + // waits on a ruling a person has not given. + RuleWaitsOnRuling DrainRule = "waits-on-ruling" + RuleRemedy DrainRule = "remedy" + RuleFields DrainRule = "fields" ) -// DrainCategories is the fixable set in the order a drain takes it (decision -// 7): clean-ups and text first, on the evidence that they merge most often; -// behaviour changes last. -var DrainCategories = []Category{"tech-debt", "documentation", "inconsistency", "drift", "bug", "ux"} - -// DrainSeverities is the severities a drain may take, in the order it takes -// them. major and critical are handed back by default. -var DrainSeverities = []Severity{SeverityNitpick, SeverityMinor} - -// DrainOrder is the ordering rule as the summary states it. -const DrainOrder = "category tech-debt, documentation, inconsistency, drift, bug, ux; " + - "then nitpick before minor; then oldest first" - // DrainVerdict is one open issue's disposition, with the rule that decided it // and the reason in words. type DrainVerdict struct { @@ -88,20 +82,27 @@ type DrainVerdict struct { Blockers []string `json:"blockers,omitempty"` } -// eligibility judges one issue by its fields alone. iss.BlockedByOpen must be -// the derived projection List fills (the blockers still in open/). The rules -// are asked in a fixed order, and the first that excludes the issue decides: -// not open, blocked, security, a category outside the fixable set, a severity -// above minor, no remedy. An issue no rule excludes is eligible. +// eligibility judges one issue by its fields alone, under the repository's +// rule r, with anchor the checkout's current release tag ("" when it has +// none). iss.BlockedByOpen must be the derived projection List fills (the +// blockers still in open/). The rules are asked in a fixed order, and the +// first that excludes the issue decides: not open, blocked, security, a +// category outside the rule's set, a severity outside it, a remedy that waits +// on a ruling, a live deferral, no remedy, the automatic filers' remedy. An +// issue no rule excludes is eligible. // // Blocked comes first because a blocked issue is not considered at all until // its blocker clears; the severity and category hand-backs come before the -// missing remedy because adding a remedy would not make such an issue -// eligible, so naming the remedy would send a reader to the wrong fix. -func eligibility(iss Issue) DrainVerdict { +// others because answering a ruling, waiting out a deferral or adding a remedy +// would not make such an issue eligible, so naming them would send a reader to +// the wrong fix. A remedy waiting on a ruling is named before a deferral +// because it says which decision is owed; a record carrying both waits on +// both. `capture defer` writes a deferral only onto a major or +// critical record, but a hand-written one on a lighter record is read alike. +func eligibility(iss Issue, r drainrule.Rule, anchor string) DrainVerdict { v := DrainVerdict{ID: iss.ID, Path: iss.Path, Severity: iss.Severity, Category: iss.Category} - decide := func(o DrainOutcome, r DrainRule, reason string) DrainVerdict { - v.Outcome, v.Rule, v.Reason = o, r, reason + decide := func(o DrainOutcome, rule DrainRule, reason string) DrainVerdict { + v.Outcome, v.Rule, v.Reason = o, rule, reason return v } switch { @@ -110,13 +111,20 @@ func eligibility(iss Issue) DrainVerdict { case len(iss.BlockedByOpen) > 0: v.Blockers = append([]string(nil), iss.BlockedByOpen...) return decide(DrainSkipped, RuleBlocked, "blocked by "+strings.Join(iss.BlockedByOpen, ", ")+", still open") - case iss.Category == "security": - return decide(DrainHandBack, RuleSecurity, "category security is always a person's") - case slices.Index(DrainCategories, iss.Category) < 0: + case iss.Category == drainrule.SecurityCategory && r.Security != drainrule.SecurityTake: + return decide(DrainHandBack, RuleSecurity, "category security is always a person's under this repository's rule") + case !r.TakesCategory(string(iss.Category)): return decide(DrainHandBack, RuleCategory, fmt.Sprintf("category %s is outside the fixable set (%s)", - iss.Category, joinCategories(DrainCategories))) - case slices.Index(DrainSeverities, iss.Severity) < 0: - return decide(DrainHandBack, RuleSeverity, fmt.Sprintf("severity %s is above the drain's (nitpick, minor)", iss.Severity)) + iss.Category, strings.Join(r.Categories, ", "))) + case !r.TakesSeverity(string(iss.Severity)): + return decide(DrainHandBack, RuleSeverity, fmt.Sprintf("severity %s is above the drain's (%s)", + iss.Severity, strings.Join(r.Severities, ", "))) + case waitsOnRuling(iss.Remedy): + return decide(DrainHandBack, RuleWaitsOnRuling, + "the remedy opens \"Waits on\": the fix waits on a person's ruling, so it is a person's until the ruling is given and the remedy rewritten") + case anchor != "" && iss.deferredAfter == anchor: + return decide(DrainHandBack, RuleDeferred, fmt.Sprintf( + "deferred past %s, the current anchor: a person carried it past this release, so it is a person's until the deferral lapses", anchor)) case strings.TrimSpace(iss.Remedy) == "": return decide(DrainIneligible, RuleRemedy, fmt.Sprintf( "no remedy: field; ineligible until someone adds one with `abcd capture remedy %s \"\"`", iss.ID)) @@ -131,24 +139,32 @@ func eligibility(iss Issue) DrainVerdict { "every field rule passes on its remedy; the host judgement over the remedy may still hand it back") } -func joinCategories(cs []Category) string { - s := make([]string, len(cs)) - for i, c := range cs { - s[i] = string(c) - } - return strings.Join(s, ", ") +// waitsOnPrefix opens a remedy whose fix waits on an unanswered ruling, the +// shape the ledger's remedies are written in ("Waits on : ..."). +const waitsOnPrefix = "waits on " + +// waitsOnRuling reports whether a remedy opens "Waits on", compared +// case-folded after leading blanks, so a lower-case spelling is held back too. +func waitsOnRuling(remedy string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(remedy)), waitsOnPrefix) +} + +// drainOrder states the ordering rule r takes eligible issues in. +func drainOrder(r drainrule.Rule) string { + return "category " + strings.Join(r.Categories, ", ") + "; then " + + strings.Join(r.Severities, " before ") + "; then oldest first" } // orderEligible sorts eligible verdicts by the drain order: category, then // severity, then oldest first (ascending id number; ids are minted in time // order, the hand-numbered ordinals before the timestamp ids). -func orderEligible(vs []DrainVerdict) { +func orderEligible(vs []DrainVerdict, r drainrule.Rule) { sort.SliceStable(vs, func(i, j int) bool { a, b := vs[i], vs[j] - if ca, cb := slices.Index(DrainCategories, a.Category), slices.Index(DrainCategories, b.Category); ca != cb { + if ca, cb := slices.Index(r.Categories, string(a.Category)), slices.Index(r.Categories, string(b.Category)); ca != cb { return ca < cb } - if sa, sb := slices.Index(DrainSeverities, a.Severity), slices.Index(DrainSeverities, b.Severity); sa != sb { + if sa, sb := slices.Index(r.Severities, string(a.Severity)), slices.Index(r.Severities, string(b.Severity)); sa != sb { return sa < sb } return issNumber(a.ID) < issNumber(b.ID) @@ -163,25 +179,48 @@ type DrainPlanRequest struct { // DrainPlan is what a drain would do over the open ledger, and writes nothing: // every open issue's disposition, the eligible ones first in the order a drain -// takes them and the rest after them by id, with the ordering rule and the -// decision record the rule is stated in. +// takes them and the rest after them by id, with the repository's rule, the +// decision record it is stated in, and every floor it loosens. type DrainPlan struct { - Record string `json:"record"` + // Record is the repository's own decision record the rule is read from. + Record string `json:"record"` + // Rule is the rule as that record states it. + Rule drainrule.Rule `json:"rule"` + // Loosened names every floor the record loosens against abcd's baseline + // (ruling H11); an empty list when it loosens none. + Loosened []string `json:"loosened"` + // Anchor is the release tag a live deferral names, when any open record + // carries a deferral; empty otherwise. + Anchor string `json:"anchor,omitempty"` Order string `json:"order"` Dispositions []DrainVerdict `json:"dispositions"` Counts map[DrainOutcome]int `json:"counts"` } -// PlanDrain classifies every open issue by field. Read-only: it takes no lock -// and writes nothing, as List does. +// PlanDrain classifies every open issue by field, under the repository's own +// rule. It refuses, before reading the ledger, a repository whose rule is +// unrecorded, ambiguous or malformed. Read-only: it takes no lock and writes +// nothing, as List does. func PlanDrain(req DrainPlanRequest) (DrainPlan, error) { + repoRoot, _, err := resolveRoots(req.RepoRoot, req.IssuesRoot) + if err != nil { + return DrainPlan{}, err + } + rule, err := drainrule.Load(repoRoot) + if err != nil { + return DrainPlan{}, err + } lr, err := List(ListRequest{RepoRoot: req.RepoRoot, IssuesRoot: req.IssuesRoot, State: StateOpen}) if err != nil { return DrainPlan{}, err } + anchor, err := liveDeferralAnchor(repoRoot, lr.Issues) + if err != nil { + return DrainPlan{}, err + } var eligible, rest []DrainVerdict for _, iss := range lr.Issues { - v := eligibility(iss) + v := eligibility(iss, rule, anchor) if v.Outcome == DrainEligible { eligible = append(eligible, v) } else { @@ -199,11 +238,14 @@ func PlanDrain(req DrainPlanRequest) (DrainPlan, error) { rest = append(rest, DrainVerdict{ID: id, Path: sk.Path, Outcome: DrainUnreadable, Rule: RuleUnreadable, Reason: fmt.Sprintf("the reader refuses the record at its %s stage: %s", sk.Layer, sk.Error)}) } - orderEligible(eligible) + orderEligible(eligible, rule) sort.SliceStable(rest, func(i, j int) bool { return issNumber(rest[i].ID) < issNumber(rest[j].ID) }) plan := DrainPlan{ - Record: EligibilityRecord, - Order: DrainOrder, + Record: rule.Record, + Rule: rule, + Loosened: rule.Loosened, + Anchor: anchor, + Order: drainOrder(rule), Dispositions: append(append([]DrainVerdict{}, eligible...), rest...), Counts: map[DrainOutcome]int{}, } @@ -213,27 +255,50 @@ func PlanDrain(req DrainPlanRequest) (DrainPlan, error) { return plan, nil } -// ErrDrainRuleUnrecorded is the refusal when the eligibility rule has no -// decision record (itd-82 decision 4, criterion 11). -var ErrDrainRuleUnrecorded = errors.New("the drain eligibility rule has no decision record") +// liveDeferralAnchor returns the checkout's current release tag when any open +// record carries a deferral, and "" when none does or the checkout has no +// release tag. The tags are read only when a deferral needs judging, and a +// failure to read them refuses the plan: a live deferral is a person's +// decision, and not knowing whether it is live must not let the record through. +func liveDeferralAnchor(repoRoot string, issues []Issue) (string, error) { + if !slices.ContainsFunc(issues, func(iss Issue) bool { return iss.deferredAfter != "" }) { + return "", nil + } + tag, found, err := changelog.LatestReleaseTag(repoRoot) + if err != nil { + return "", fmt.Errorf("drain: an open record carries a deferral, and the release tags that say whether it is live could not be read: %w", err) + } + if !found { + return "", nil + } + return tag.Tag(), nil +} + +// ErrDrainRuleUnrecorded is the refusal when the drained repository holds no +// record of its eligibility rule (itd-82 decision 4, criterion 11; ruling BX2). +var ErrDrainRuleUnrecorded = drainrule.ErrUnrecorded // ErrDrainRunUnbuilt is the refusal of the run itself: the issue-keyed lane a // drain hands each eligible issue to (itd-2609201916151817 decision 10) is // not built, so there is nothing safe to start. var ErrDrainRunUnbuilt = errors.New("the drain run is not built") -// DrainStart is the check an unattended drain makes before it starts. It -// refuses without the eligibility rule's decision record, naming the record it -// needs, and otherwise refuses because the run has no lane to hand an issue -// to yet. Writes nothing. -func DrainStart() error { return drainStartCheck(EligibilityRecord) } - -func drainStartCheck(record string) error { - if record == "" { - return fmt.Errorf("%w: an unattended drain needs the decision record itd-82 decision 4 owes "+ - "(the rule for which issues need no decision) before it starts", ErrDrainRuleUnrecorded) +// DrainStart is the check an unattended drain makes before it starts. It reads +// the repository's own rule and refuses without it, naming how to add it, or +// when it is ambiguous or malformed. With the rule it still refuses, because +// the run has no lane to hand an issue to yet, naming the rule's record and +// every floor the record loosens (ruling H11). Writes nothing. +func DrainStart(repoRoot string) error { + rule, err := drainrule.Load(repoRoot) + if err != nil { + return err + } + loosened := "" + if len(rule.Loosened) > 0 { + loosened = fmt.Sprintf("; this repository's rule loosens abcd's floors, letting a drain take %s", + strings.Join(rule.Loosened, ", ")) } return fmt.Errorf("%w: the issue-keyed lane it hands each eligible issue to does not exist yet "+ - "(itd-2609201916151817 decision 10); `abcd drain --dry-run` shows what it would do under %s", - ErrDrainRunUnbuilt, record) + "(itd-2609201916151817 decision 10); `abcd drain --dry-run` shows what it would do under %s%s", + ErrDrainRunUnbuilt, rule.Record, loosened) } diff --git a/internal/core/capture/eligible_test.go b/internal/core/capture/eligible_test.go index e391d89e0..844560835 100644 --- a/internal/core/capture/eligible_test.go +++ b/internal/core/capture/eligible_test.go @@ -6,6 +6,9 @@ import ( "path/filepath" "strings" "testing" + + "github.com/intentdriven/abcd/internal/core/drainrule" + "github.com/intentdriven/abcd/internal/gittest" ) // The drain's field-only eligibility (itd-82 decisions 4 and 7, @@ -22,9 +25,31 @@ type drainFixture struct { func newDrainFixture(t *testing.T) drainFixture { t.Helper() repo, ir := ledger(t) + writeRuleRecord(t, repo, drainrule.ProposalFrontmatter()) return drainFixture{t: t, repo: repo, ir: ir} } +// strictRuleRecord is the id the fixtures' strict record carries. +const strictRuleRecord = "adr-2609300000000001" + +// writeRuleRecord writes the repository's own drain eligibility record (ruling +// BX2): an accepted decision record carrying the drain fields given. +func writeRuleRecord(t *testing.T, repo, fields string) { + t.Helper() + dir := filepath.Join(repo, filepath.FromSlash(drainrule.ADRsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := "---\nid: " + strictRuleRecord + "\nslug: drain-rule\nstatus: accepted\ndate: 2026-09-30\n" + fields + "---\n\n# ADR\n" + if err := os.WriteFile(filepath.Join(dir, "2609300000000001-drain-rule.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// loosenedFields is a project rule that loosens both floors H11 names. +const loosenedFields = "drain_categories: [tech-debt, documentation, inconsistency, drift, bug, ux]\n" + + "drain_severities: [nitpick, minor, major]\ndrain_security: take\ndrain_remedy: required\n" + // file captures one open record. An empty remedy files a LEGACY record: // capture refuses a new issue without a remedy (ruling BX3), so the record is // filed with one and the key is then taken out, which is the shape every @@ -162,30 +187,30 @@ func TestDrainRoutesAMixedLedgerByField(t *testing.T) { func TestEligibilityIsTheFieldRuleAndNothingElse(t *testing.T) { for _, cat := range []Category{"tech-debt", "documentation", "inconsistency", "drift", "bug", "ux"} { for _, sev := range []Severity{SeverityNitpick, SeverityMinor} { - v := eligibility(Issue{ID: "iss-1", Category: cat, Severity: sev, Remedy: "r", Status: StateOpen}) + v := eligibility(Issue{ID: "iss-1", Category: cat, Severity: sev, Remedy: "r", Status: StateOpen}, drainrule.Baseline(), "") if v.Outcome != DrainEligible { t.Errorf("%s/%s with a remedy: %s (%s), want eligible", cat, sev, v.Outcome, v.Reason) } } } for _, cat := range []Category{"process", "observation", "architectural-insight", "future-work-seed", "lapse"} { - v := eligibility(Issue{ID: "iss-1", Category: cat, Severity: SeverityMinor, Remedy: "r", Status: StateOpen}) + v := eligibility(Issue{ID: "iss-1", Category: cat, Severity: SeverityMinor, Remedy: "r", Status: StateOpen}, drainrule.Baseline(), "") if v.Outcome != DrainHandBack || v.Rule != RuleCategory { t.Errorf("%s: %s/%s, want handback/category", cat, v.Outcome, v.Rule) } } for _, sev := range []Severity{SeverityMajor, SeverityCritical} { - v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: sev, Remedy: "r", Status: StateOpen}) + v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: sev, Remedy: "r", Status: StateOpen}, drainrule.Baseline(), "") if v.Outcome != DrainHandBack || v.Rule != RuleSeverity { t.Errorf("%s: %s/%s, want handback/severity", sev, v.Outcome, v.Rule) } } // A remedy of blanks is no remedy. - if v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: SeverityMinor, Remedy: " \t", Status: StateOpen}); v.Outcome != DrainIneligible { + if v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: SeverityMinor, Remedy: " \t", Status: StateOpen}, drainrule.Baseline(), ""); v.Outcome != DrainIneligible { t.Errorf("a blank remedy: %s, want ineligible", v.Outcome) } // A record that is not open is never a drain's to take. - if v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: SeverityMinor, Remedy: "r", Status: StateResolved}); v.Outcome == DrainEligible { + if v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: SeverityMinor, Remedy: "r", Status: StateResolved}, drainrule.Baseline(), ""); v.Outcome == DrainEligible { t.Errorf("a resolved record was eligible") } } @@ -237,8 +262,8 @@ func TestDrainPlanWritesNothing(t *testing.T) { if after := snapshotTree(t, f.repo); after != before { t.Fatalf("the plan changed the tree:\nbefore %s\nafter %s", before, after) } - if p.Record != EligibilityRecord || !strings.HasPrefix(p.Record, "adr-") { - t.Errorf("the plan names the rule's record %q, want %q", p.Record, EligibilityRecord) + if p.Record != strictRuleRecord { + t.Errorf("the plan names the rule's record %q, want the repository's own %q", p.Record, strictRuleRecord) } } @@ -259,24 +284,180 @@ func TestDrainPlanGivesAnUnreadableOpenRecordItsOwnDisposition(t *testing.T) { } } -// TestADrainRefusesToStartWithoutTheRuleRecord is criterion 11: with no -// decision record for the eligibility rule, the run refuses naming the record it -// needs; with one, it still refuses, because the issue-keyed lane it would hand -// each issue to is not built, and it says so rather than pretending to run. -func TestADrainRefusesToStartWithoutTheRuleRecord(t *testing.T) { - err := drainStartCheck("") - if !errors.Is(err, ErrDrainRuleUnrecorded) { - t.Fatalf("no rule record: got %v, want ErrDrainRuleUnrecorded", err) - } - if !strings.Contains(err.Error(), "itd-82") || !strings.Contains(err.Error(), "decision record") { - t.Errorf("the refusal does not name the record it needs: %v", err) - } - err = DrainStart() +// TestADrainRefusesARepositoryWithoutItsOwnRule is criterion 11 under ruling +// BX2: the repository must hold the eligibility decision in its own record, and +// until it does both the dry run and the start refuse, naming how to add it. +// With the record, the start still refuses, because the issue-keyed lane it +// would hand each issue to is not built, and it says so rather than pretending +// to run. +func TestADrainRefusesARepositoryWithoutItsOwnRule(t *testing.T) { + repo, ir := ledger(t) + f := drainFixture{t: t, repo: repo, ir: ir} + f.file("iss-1", SeverityMinor, "bug", "r") + before := snapshotTree(t, repo) + if p, err := PlanDrain(DrainPlanRequest{RepoRoot: repo, IssuesRoot: ir}); !errors.Is(err, ErrDrainRuleUnrecorded) { + t.Fatalf("a dry run without the repository's rule: plan %+v, err %v; want ErrDrainRuleUnrecorded", p, err) + } else if !strings.Contains(err.Error(), "ahoy install") || !strings.Contains(err.Error(), drainrule.FieldCategories) { + t.Errorf("the refusal does not name how to add the record: %v", err) + } + err := DrainStart(repo) + if !errors.Is(err, ErrDrainRuleUnrecorded) || !strings.Contains(err.Error(), "ahoy install") { + t.Fatalf("a start without the repository's rule: got %v, want ErrDrainRuleUnrecorded naming ahoy install", err) + } + if after := snapshotTree(t, repo); after != before { + t.Fatalf("a refused drain wrote:\nbefore %s\nafter %s", before, after) + } + + writeRuleRecord(t, repo, drainrule.ProposalFrontmatter()) + err = DrainStart(repo) if !errors.Is(err, ErrDrainRunUnbuilt) { t.Fatalf("with the record: got %v, want ErrDrainRunUnbuilt", err) } - if !strings.Contains(err.Error(), "--dry-run") { - t.Errorf("the refusal does not name the dry run it can do: %v", err) + if !strings.Contains(err.Error(), "--dry-run") || !strings.Contains(err.Error(), strictRuleRecord) { + t.Errorf("the refusal does not name the dry run and the rule's record: %v", err) + } + if strings.Contains(err.Error(), "loosen") { + t.Errorf("the strict rule's start names a loosened floor: %v", err) + } +} + +// TestADrainAppliesTheRepositorysOwnRule: the rule is the record's, not the +// binary's. A narrowed record hands back what the baseline would take. +func TestADrainAppliesTheRepositorysOwnRule(t *testing.T) { + repo, ir := ledger(t) + writeRuleRecord(t, repo, "drain_categories: [documentation]\ndrain_severities: [nitpick]\n"+ + "drain_security: handback\ndrain_remedy: required\n") + f := drainFixture{t: t, repo: repo, ir: ir} + f.file("iss-1", SeverityNitpick, "documentation", "fix the typo") + f.file("iss-2", SeverityMinor, "documentation", "fix the page") + f.file("iss-3", SeverityNitpick, "bug", "guard the nil map") + p := f.plan() + for id, want := range map[string]string{ + "iss-1": "eligible/fields", "iss-2": "handback/severity", "iss-3": "handback/category", + } { + v := verdictOf(t, p, id) + if got := string(v.Outcome) + "/" + string(v.Rule); got != want { + t.Errorf("%s: %s (%s), want %s", id, got, v.Reason, want) + } + } + if !strings.Contains(verdictOf(t, p, "iss-2").Reason, "(nitpick)") { + t.Errorf("the severity hand-back does not name the repository's severities: %s", verdictOf(t, p, "iss-2").Reason) + } + if len(p.Loosened) != 0 || p.Loosened == nil { + t.Errorf("a narrowed rule: loosened = %#v, want an empty list", p.Loosened) + } +} + +// TestALoosenedRuleIsLoud is ruling H11: a project's record may let a drain +// take major issues and security issues, and every floor it loosens is named +// in the dry run's plan and in the start's refusal. +func TestALoosenedRuleIsLoud(t *testing.T) { + repo, ir := ledger(t) + writeRuleRecord(t, repo, loosenedFields) + f := drainFixture{t: t, repo: repo, ir: ir} + f.file("iss-1", SeverityMajor, "bug", "rewrite the parser") + f.file("iss-2", SeverityMinor, "security", "tighten the check") + f.file("iss-3", SeverityCritical, "bug", "stop the data loss") + p := f.plan() + if strings.Join(p.Loosened, ", ") != "severity major, security" { + t.Errorf("the plan names loosened floors %v, want [severity major security]", p.Loosened) + } + for id, want := range map[string]string{ + "iss-1": "eligible/fields", "iss-2": "eligible/fields", "iss-3": "handback/severity", + } { + v := verdictOf(t, p, id) + if got := string(v.Outcome) + "/" + string(v.Rule); got != want { + t.Errorf("%s: %s (%s), want %s", id, got, v.Reason, want) + } + } + // Security is taken last, after every fixable category. + if !strings.Contains(p.Order, "ux, security") || !strings.Contains(p.Order, "nitpick before minor before major") { + t.Errorf("the order does not state the loosened rule: %s", p.Order) + } + err := DrainStart(repo) + for _, want := range []string{"loosens", "severity major", "security"} { + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("the start's refusal does not name %q: %v", want, err) + } + } +} + +// TestAMalformedRuleRefusesTheDrain: a partial record refuses the dry run and +// the start alike, never falling back to either the baseline or a looser rule. +func TestAMalformedRuleRefusesTheDrain(t *testing.T) { + repo, ir := ledger(t) + writeRuleRecord(t, repo, "drain_categories: [bug]\ndrain_severities: [nitpick, minor, major]\ndrain_security: take\n") + f := drainFixture{t: t, repo: repo, ir: ir} + f.file("iss-1", SeverityMinor, "bug", "r") + if p, err := PlanDrain(DrainPlanRequest{RepoRoot: repo, IssuesRoot: ir}); !errors.Is(err, drainrule.ErrMalformed) { + t.Fatalf("a partial record: plan %+v, err %v; want ErrMalformed", p, err) + } + if err := DrainStart(repo); !errors.Is(err, drainrule.ErrMalformed) || !strings.Contains(err.Error(), drainrule.FieldRemedy) { + t.Fatalf("a partial record's start: %v; want ErrMalformed naming %s", err, drainrule.FieldRemedy) + } +} + +// TestADrainHandsBackARecordWaitingOnAPerson is the gap the remedy lanes found: +// a record whose remedy opens "Waits on" names a fix that waits on a person's +// ruling, and a record whose deferral past the current anchor tag is live was +// carried past this release by a person. Both are handed back, each naming its +// rule, whatever the project's rule would otherwise let through. A deferral +// past an earlier tag has lapsed and holds nothing back. +func TestADrainHandsBackARecordWaitingOnAPerson(t *testing.T) { + r := gittest.NewRepo(t) + r.Commit("root") + r.Git("tag", "v0.1.0") + r.Commit("next") + r.Git("tag", "v0.2.0") + repo := r.Root() + ir := filepath.Join(repo, LedgerRelPath) + writeRuleRecord(t, repo, loosenedFields) + f := drainFixture{t: t, repo: repo, ir: ir} + f.file("iss-1", SeverityMinor, "bug", "Waits on ruling G (keep or drop the flag): if dropped, delete it") + f.file("iss-2", SeverityMajor, "bug", "rewrite the parser") + f.file("iss-3", SeverityMajor, "bug", "rewrite the lexer") + f.file("iss-4", SeverityMinor, "bug", "waits on the facilitator's round trip: then rerun it") + f.file("iss-5", SeverityMinor, "bug", "guard the nil map; it waits on nothing") + setDeferral(t, ir, "iss-2", "v0.2.0") + setDeferral(t, ir, "iss-3", "v0.1.0") + + p := f.plan() + if p.Anchor != "v0.2.0" { + t.Errorf("the plan's anchor = %q, want v0.2.0", p.Anchor) + } + cases := map[string]struct{ want, reason string }{ + "iss-1": {"handback/waits-on-ruling", "Waits on"}, + "iss-2": {"handback/deferred", "v0.2.0"}, + "iss-3": {"eligible/fields", ""}, + "iss-4": {"handback/waits-on-ruling", "Waits on"}, + "iss-5": {"eligible/fields", ""}, + } + for id, c := range cases { + v := verdictOf(t, p, id) + if got := string(v.Outcome) + "/" + string(v.Rule); got != c.want { + t.Errorf("%s: %s (%s), want %s", id, got, v.Reason, c.want) + } + if !strings.Contains(v.Reason, c.reason) { + t.Errorf("%s: the reason %q does not name %q", id, v.Reason, c.reason) + } + } +} + +// setDeferral writes the waiver pair onto an open record by hand, the shape +// `capture defer` writes. +func setDeferral(t *testing.T, ir, id, after string) { + t.Helper() + matches, _ := filepath.Glob(filepath.Join(ir, "open", id+"-*.md")) + if len(matches) != 1 { + t.Fatalf("no open record for %s", id) + } + raw, err := os.ReadFile(matches[0]) + if err != nil { + t.Fatal(err) + } + s := strings.Replace(string(raw), "\nslug: ", "\ndeferred_after: \""+after+"\"\ndeferral_reason: a person's reason\nslug: ", 1) + if err := os.WriteFile(matches[0], []byte(s), 0o644); err != nil { + t.Fatal(err) } } @@ -306,12 +487,16 @@ func snapshotTree(t *testing.T, root string) string { return b.String() } -// TestTheEligibilityRuleIsRecordedAndAccepted is the tree half of criterion 11 -// and itd-82 decision 4: the record the binary names as the rule's is an -// accepted decision record in this repository's store, and the brief's -// invariants cite it. Deleting the record, leaving it proposed, or dropping the -// invariant fails here, before a drain could ship without them. -func TestTheEligibilityRuleIsRecordedAndAccepted(t *testing.T) { +// abcdsOwnRuleRecord is the record abcd's own repository states its drain rule +// in, cited by the brief's invariants. +const abcdsOwnRuleRecord = "adr-2609291342092738" + +// TestAbcdsOwnDrainRuleIsTheStrictBaseline is the tree half of criterion 11, +// itd-82 decision 4 and ruling H11's note: this repository holds its own drain +// rule in an accepted record, that record states abcd's strict baseline and +// loosens nothing, and the brief's invariants cite it. Loosening the record, +// deleting it, leaving it proposed, or dropping the invariant fails here. +func TestAbcdsOwnDrainRuleIsTheStrictBaseline(t *testing.T) { dir, err := os.Getwd() if err != nil { t.Fatal(err) @@ -326,23 +511,23 @@ func TestTheEligibilityRuleIsRecordedAndAccepted(t *testing.T) { } dir = parent } - stamp := strings.TrimPrefix(EligibilityRecord, "adr-") - matches, err := filepath.Glob(filepath.Join(dir, ".abcd", "development", "decisions", "adrs", stamp+"-*.md")) - if err != nil || len(matches) != 1 { - t.Fatalf("the rule's record %s: want one file in the ADR store, got %v (%v)", EligibilityRecord, matches, err) - } - adr, err := os.ReadFile(matches[0]) + r, err := drainrule.Load(dir) if err != nil { - t.Fatal(err) + t.Fatalf("abcd's own repository holds no readable drain rule: %v", err) + } + if r.Record != abcdsOwnRuleRecord { + t.Errorf("abcd's drain rule is stated in %s, want %s", r.Record, abcdsOwnRuleRecord) } - if !strings.Contains(string(adr), "\nid: "+EligibilityRecord+"\n") || !strings.Contains(string(adr), "\nstatus: accepted\n") { - t.Errorf("%s is not an accepted record with that id", filepath.Base(matches[0])) + b := drainrule.Baseline() + if len(r.Loosened) != 0 || strings.Join(r.Categories, ",") != strings.Join(b.Categories, ",") || + strings.Join(r.Severities, ",") != strings.Join(b.Severities, ",") || r.Security != b.Security { + t.Errorf("abcd's own drain rule is not the strict baseline: %+v", r) } inv, err := os.ReadFile(filepath.Join(dir, ".abcd", "development", "brief", "02-constraints", "03-invariants.md")) if err != nil { t.Fatal(err) } - if !strings.Contains(string(inv), "[adr-"+stamp+"]") { - t.Errorf("the brief's invariants do not cite %s", EligibilityRecord) + if !strings.Contains(string(inv), "["+abcdsOwnRuleRecord+"]") { + t.Errorf("the brief's invariants do not cite %s", abcdsOwnRuleRecord) } } diff --git a/internal/core/capture/validate.go b/internal/core/capture/validate.go index 0eadcdd3d..258bd6261 100644 --- a/internal/core/capture/validate.go +++ b/internal/core/capture/validate.go @@ -54,6 +54,7 @@ func issueFromFrontmatter(fm map[string]any, status State, path, body string) Is Status: status, Path: path, Body: body, + deferredAfter: asString(fm["deferred_after"]), } iss.RelatedIntents = asStrList(fm["related_intents"]) iss.RelatedSpecs = asStrList(fm["related_specs"]) diff --git a/internal/core/drainrule/drainrule.go b/internal/core/drainrule/drainrule.go new file mode 100644 index 000000000..52227cb14 --- /dev/null +++ b/internal/core/drainrule/drainrule.go @@ -0,0 +1,400 @@ +// Package drainrule reads the drained repository's own record of which open +// issues an unattended drain may take alone (itd-82 decision 4; the product +// thinker's rulings BX2 and H11 of 2026-09-29). +// +// BX2, verbatim: "the PROJECT MUST HOLD the eligibility decision in its own +// record (e.g. added at setup); drain refuses there until it does." H11, +// verbatim: "MAY LOOSEN abcd's floors (a project may let drain take +// major/critical and security issues). NOTE for the lane: make a loosened floor +// loud (drain --dry-run and the drain start name every floor the project +// loosened), and keep abcd's own repository at the stricter default." +// +// The record is an accepted decision record in the repository's own store +// (.abcd/development/decisions/adrs/) whose frontmatter carries four fields: +// +// drain_categories: [tech-debt, documentation, inconsistency, drift, bug, ux] +// drain_severities: [nitpick, minor] +// drain_security: handback +// drain_remedy: required +// +// The baseline is abcd's strict rule, bundled here; a record is measured +// against it, and every floor it loosens is named in Rule.Loosened. A record +// may narrow the fixable set and the severities, may widen the severities to +// major and critical, and may take security issues. It may not widen the +// categories past the fixable set (the other categories are decisions by kind), +// and it may not drop the remedy: the remedy is the brief a lane works from. +// Anything else it says, or fails to say, refuses: a partial or malformed +// record never falls back to a looser rule, or to a stricter one in silence. +// +// The package is a leaf over the frontmatter scanner and the issue schema, so +// the capture package (which applies the rule) and the setup offer (which +// writes the baseline as a record) both read the one definition. +package drainrule + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "slices" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/core/frontmatter" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/recordid" +) + +// ADRsRelDir is the decision store the rule is read from, repo-relative and +// slash-separated. A test pins it to core/decide's own constant. +const ADRsRelDir = ".abcd/development/decisions/adrs" + +// The record's four fields. +const ( + FieldCategories = "drain_categories" + FieldSeverities = "drain_severities" + FieldSecurity = "drain_security" + FieldRemedy = "drain_remedy" +) + +// fieldPrefix marks a frontmatter key as the drain rule's: a record carrying +// any key with it is a drain rule record, and every such key must be one of +// the four, so a misspelt field refuses rather than being ignored. +const fieldPrefix = "drain_" + +// The values drain_security and drain_remedy take. +const ( + // SecurityHandBack: a security issue is always a person's (the baseline). + SecurityHandBack = "handback" + // SecurityTake: a drain may take a security issue that passes every other + // rule, a loosened floor (H11). + SecurityTake = "take" + // RemedyRequired: a drain takes no issue without a remedy. It is the only + // value: the remedy is the brief the issue-keyed lane works from + // (itd-2609201916151817 decision 10), so a project cannot drop it. + RemedyRequired = "required" +) + +// SecurityCategory is the issue category drain_security decides. +const SecurityCategory = "security" + +// baselineCategories is the fixable set in the order a drain takes it (itd-82 +// decision 7): clean-ups and text first, behaviour changes last. A security +// issue a project lets through comes after all of them. +var baselineCategories = []string{"tech-debt", "documentation", "inconsistency", "drift", "bug", "ux"} + +// baselineSeverities is the severities abcd's baseline lets a drain take. +var baselineSeverities = []string{"nitpick", "minor"} + +// Rule is one repository's drain eligibility rule, as its record states it. +type Rule struct { + // Record is the decision record's id (adr-N); empty for the bundled + // baseline, which is never applied as a repository's rule. + Record string `json:"record"` + // Path is the record's repo-relative, slash-separated path. + Path string `json:"path"` + // Categories are the categories a drain may take, in the order it takes + // them; "security" is last when the record takes it. + Categories []string `json:"categories"` + // Severities are the severities a drain may take, in the order it takes + // them (nitpick, minor, major, critical). + Severities []string `json:"severities"` + Security string `json:"security"` + Remedy string `json:"remedy"` + // Loosened names every floor the record loosens against abcd's baseline, + // "severity major", "severity critical" and "security", in that order. + // Always non-nil, so a JSON reader sees an empty list rather than none. + Loosened []string `json:"loosened"` +} + +// Baseline is abcd's strict rule: the fixable set, nitpick and minor, security +// handed back, a remedy required. abcd's own repository states exactly this in +// adr-2609291342092738, and the setup offer writes it. +func Baseline() Rule { + return Rule{ + Categories: slices.Clone(baselineCategories), + Severities: slices.Clone(baselineSeverities), + Security: SecurityHandBack, + Remedy: RemedyRequired, + Loosened: []string{}, + } +} + +// TakesCategory reports whether the rule lets a drain take category c. +func (r Rule) TakesCategory(c string) bool { return slices.Contains(r.Categories, c) } + +// TakesSeverity reports whether the rule lets a drain take severity s. +func (r Rule) TakesSeverity(s string) bool { return slices.Contains(r.Severities, s) } + +// The refusals. Each is wrapped with the record and field it is about. +var ( + // ErrUnrecorded: the repository holds no accepted record of the rule. + ErrUnrecorded = errors.New("this repository holds no drain eligibility record") + // ErrMalformed: the record is partial, misspelt, or states a value the rule + // does not take. + ErrMalformed = errors.New("the drain eligibility record is malformed") + // ErrAmbiguous: more than one accepted record states the rule. + ErrAmbiguous = errors.New("more than one accepted record states the drain eligibility rule") +) + +// HowToAdd is the remedy every ErrUnrecorded refusal names. +const HowToAdd = "add it: run `abcd ahoy install` at a terminal and accept the drain rule it offers, " + + "which writes abcd's strict baseline as an accepted decision record; or give an accepted decision record " + + "in " + ADRsRelDir + "/ the four fields " + FieldCategories + ", " + FieldSeverities + ", " + + FieldSecurity + " and " + FieldRemedy + " (mint one with `abcd decide \"\"`)" + +// Load reads the repository's drain eligibility rule from its decision store. +// It refuses, with ErrUnrecorded, a repository whose store holds no accepted +// record carrying the drain fields; with ErrAmbiguous, one holding two; and +// with ErrMalformed, a record that is partial or states a value the rule does +// not take. +// +// The store is read inside an os.Root at the checkout, so a store or record +// that is a symlink leaving the checkout is refused, never followed: the rule +// is the drained tree's committed history, and a rule from elsewhere is not it. +func Load(repoRoot string) (Rule, error) { + root, err := os.OpenRoot(repoRoot) + if err != nil { + return Rule{}, fmt.Errorf("drain rule: opening the checkout: %w", err) + } + defer root.Close() + entries, err := fs.ReadDir(root.FS(), ADRsRelDir) + if errors.Is(err, fs.ErrNotExist) { + return Rule{}, fmt.Errorf("%w: it has no decision store at %s; %s", ErrUnrecorded, ADRsRelDir, HowToAdd) + } + if err != nil { + return Rule{}, fmt.Errorf("drain rule: reading %s: %w", ADRsRelDir, err) + } + type candidate struct { + id, rel, status string + fields map[string]frontmatter.Field + dups []string + } + var accepted, other []candidate + for _, e := range entries { + if e.IsDir() || recordid.ADRFileID(e.Name()) == "" { + continue + } + rel := path.Join(ADRsRelDir, e.Name()) + raw, err := root.ReadFile(rel) + if err != nil { + return Rule{}, fmt.Errorf("drain rule: reading %s: %w", rel, err) + } + lines := strings.Split(string(raw), "\n") + fields := frontmatter.Fields(lines) + if !carriesDrainFields(fields) { + continue + } + c := candidate{rel: rel, fields: fields} + c.id, _ = frontmatter.ScalarString(fields["id"].Value) + if c.id == "" { + c.id = recordid.ADRFileID(e.Name()) + } + c.status, _ = frontmatter.ScalarString(fields["status"].Value) + for _, d := range frontmatter.Duplicates(lines) { + if strings.HasPrefix(d.Key, fieldPrefix) { + c.dups = append(c.dups, d.Key) + } + } + if c.status == "accepted" { + accepted = append(accepted, c) + } else { + other = append(other, c) + } + } + switch len(accepted) { + case 0: + var named []string + for _, c := range other { + named = append(named, fmt.Sprintf("%s carries the drain fields but is %s, not accepted", c.id, orNone(c.status))) + } + note := "" + if len(named) > 0 { + note = " (" + strings.Join(named, "; ") + ")" + } + return Rule{}, fmt.Errorf("%w: no accepted decision record in %s carries the drain fields%s; %s", + ErrUnrecorded, ADRsRelDir, note, HowToAdd) + case 1: + default: + ids := make([]string, len(accepted)) + for i, c := range accepted { + ids[i] = c.id + } + return Rule{}, fmt.Errorf("%w: %s each carry the drain fields; supersede all but one, so the rule a drain applies is one record's", + ErrAmbiguous, strings.Join(ids, " and ")) + } + c := accepted[0] + if len(c.dups) > 0 { + return Rule{}, fmt.Errorf("%w: %s (%s) states %s more than once; state each field once", + ErrMalformed, c.id, c.rel, strings.Join(c.dups, ", ")) + } + r, err := parse(c.fields) + if err != nil { + return Rule{}, fmt.Errorf("%w: %s (%s): %s", ErrMalformed, c.id, c.rel, err.Error()) + } + r.Record, r.Path = c.id, c.rel + return r, nil +} + +func orNone(s string) string { + if s == "" { + return "without a status" + } + return s +} + +// carriesDrainFields reports whether a record's frontmatter states any part of +// the drain rule. +func carriesDrainFields(fields map[string]frontmatter.Field) bool { + for k := range fields { + if strings.HasPrefix(k, fieldPrefix) { + return true + } + } + return false +} + +var knownFields = []string{FieldCategories, FieldSeverities, FieldSecurity, FieldRemedy} + +// parse reads the four fields and measures them against the baseline. +func parse(fields map[string]frontmatter.Field) (Rule, error) { + var unknown []string + for k := range fields { + if strings.HasPrefix(k, fieldPrefix) && !slices.Contains(knownFields, k) { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return Rule{}, fmt.Errorf("%s is not a drain rule field; the fields are %s", + strings.Join(unknown, ", "), strings.Join(knownFields, ", ")) + } + for _, k := range knownFields { + if _, ok := fields[k]; !ok { + return Rule{}, fmt.Errorf("%s is missing; a drain rule states all of %s", k, strings.Join(knownFields, ", ")) + } + } + + cats, err := flowList(fields, FieldCategories) + if err != nil { + return Rule{}, err + } + for _, c := range cats { + switch { + case slices.Contains(baselineCategories, c): + case c == SecurityCategory: + return Rule{}, fmt.Errorf("%s lists security; whether a drain takes security issues is %s's to state (%s or %s)", + FieldCategories, FieldSecurity, SecurityHandBack, SecurityTake) + case slices.Contains(issueschema.Categories, c): + return Rule{}, fmt.Errorf("%s lists %s, a category a person decides by kind; a project's rule may narrow the fixable set (%s), never widen it", + FieldCategories, c, strings.Join(baselineCategories, ", ")) + default: + return Rule{}, fmt.Errorf("%s lists %q, which is not an issue category; the fixable set is %s", + FieldCategories, c, strings.Join(baselineCategories, ", ")) + } + } + sevs, err := flowList(fields, FieldSeverities) + if err != nil { + return Rule{}, err + } + for _, s := range sevs { + if !slices.Contains(issueschema.Severities, s) { + return Rule{}, fmt.Errorf("%s lists %q, which is not a severity; the severities are %s", + FieldSeverities, s, strings.Join(issueschema.Severities, ", ")) + } + } + security, _ := frontmatter.ScalarString(fields[FieldSecurity].Value) + if security != SecurityHandBack && security != SecurityTake { + return Rule{}, fmt.Errorf("%s is %q; it is %s (abcd's baseline) or %s", FieldSecurity, security, SecurityHandBack, SecurityTake) + } + remedy, _ := frontmatter.ScalarString(fields[FieldRemedy].Value) + if remedy != RemedyRequired { + return Rule{}, fmt.Errorf("%s is %q; it is %s, the only value: the remedy is the brief a lane works from, so no rule takes an issue without one", + FieldRemedy, remedy, RemedyRequired) + } + + r := Rule{Security: security, Remedy: remedy, Loosened: []string{}} + for _, c := range baselineCategories { + if slices.Contains(cats, c) { + r.Categories = append(r.Categories, c) + } + } + for _, s := range issueschema.Severities { + if slices.Contains(sevs, s) { + r.Severities = append(r.Severities, s) + if !slices.Contains(baselineSeverities, s) { + r.Loosened = append(r.Loosened, "severity "+s) + } + } + } + if security == SecurityTake { + r.Categories = append(r.Categories, SecurityCategory) + r.Loosened = append(r.Loosened, SecurityCategory) + } + return r, nil +} + +// flowList reads a field written as an inline list, `[a, b]`, refusing any +// other shape (a block sequence reads as an empty value to the line scanner, +// and would otherwise state nothing) and an empty list. +func flowList(fields map[string]frontmatter.Field, key string) ([]string, error) { + v := strings.TrimSpace(fields[key].Value) + if !strings.HasPrefix(v, "[") || !strings.HasSuffix(v, "]") { + return nil, fmt.Errorf("%s is not an inline list; write it as %s: [a, b]", key, key) + } + items := frontmatter.StringList(v) + if len(items) == 0 { + return nil, fmt.Errorf("%s is empty; a rule that takes nothing is not stated this way (narrow it, or leave the drain unrun)", key) + } + return items, nil +} + +// ProposalTitle is the title the setup offer gives the record it writes. +const ProposalTitle = "A drain takes an issue alone only when its fields say it needs no decision" + +// ProposalFrontmatter is the baseline as the four frontmatter lines a record +// carries, each ending in a newline. +func ProposalFrontmatter() string { + b := Baseline() + return FieldCategories + ": [" + strings.Join(b.Categories, ", ") + "]\n" + + FieldSeverities + ": [" + strings.Join(b.Severities, ", ") + "]\n" + + FieldSecurity + ": " + b.Security + "\n" + + FieldRemedy + ": " + b.Remedy + "\n" +} + +// ProposalBody is the record's body below its title: the four sections every +// decision record carries, stating the baseline in words. +func ProposalBody() string { + return "## Context\n\n" + + "`abcd drain` works the open issue ledger unattended: it fixes what needs no decision and hands the " + + "rest back to a person. Which issue a machine may take alone is this repository's decision, and the " + + "drain refuses to run here until an accepted record states it.\n\n" + + "## Decision\n\n" + + "We will let a drain take an open issue alone only when its fields say it needs no decision, and " + + "hand every other open issue back by the rule that excluded it. The four `drain_` fields in this " + + "record's frontmatter are the rule the drain reads:\n\n" + + "- `" + FieldCategories + "`: the categories a drain may take, from the fixable set " + + "(`" + strings.Join(baselineCategories, "`, `") + "`); every other category is a person's.\n" + + "- `" + FieldSeverities + "`: the severities a drain may take; `major` and `critical` are a person's " + + "unless listed here.\n" + + "- `" + FieldSecurity + "`: `" + SecurityHandBack + "` keeps every security issue a person's; `" + + SecurityTake + "` lets a drain take one that passes every other rule.\n" + + "- `" + FieldRemedy + "`: `" + RemedyRequired + "`; an issue without a remedy, or with the value an " + + "automatic filer writes, is never taken.\n\n" + + "These values are abcd's strict baseline. Listing `major` or `critical`, or setting `" + FieldSecurity + + ": " + SecurityTake + "`, loosens a floor, and `abcd drain --dry-run` and the drain start name every " + + "floor loosened.\n\n" + + "## Alternatives Considered\n\n" + + "- **A model classifies each issue.** Rejected: a model detects its own ambiguity badly, and a " + + "classifier that can let an issue through is the failure this rule exists to prevent.\n" + + "- **Every issue with a remedy.** Rejected: a remedy says what someone proposed, not that the " + + "proposal needs no decision.\n" + + "- **The fields, read in a fixed order (chosen).** Each exclusion names the rule a person reads to " + + "act on it.\n\n" + + "## Consequences\n\n" + + "- A change to what a drain may take is a change to this record, reviewed like code; a pull request " + + "that loosens it is one a reviewer reads as a trust change.\n" + + "- An issue whose remedy waits on a ruling, or whose deferral past the current release is live, is " + + "handed back whatever this record says.\n" +} diff --git a/internal/core/drainrule/drainrule_test.go b/internal/core/drainrule/drainrule_test.go new file mode 100644 index 000000000..c07c772dd --- /dev/null +++ b/internal/core/drainrule/drainrule_test.go @@ -0,0 +1,236 @@ +package drainrule + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/decide" +) + +// The drained repository's own eligibility record (ruling BX2) and the floors +// it may loosen (ruling H11). Every test writes a decision store into a +// temporary checkout and reads it back through Load, the one reader the drain +// uses. + +// strictFields is the baseline as a record states it. +const strictFields = "drain_categories: [tech-debt, documentation, inconsistency, drift, bug, ux]\n" + + "drain_severities: [nitpick, minor]\n" + + "drain_security: handback\n" + + "drain_remedy: required\n" + +// writeADR writes one decision record into repo's store. +func writeADR(t *testing.T, repo, name, id, status, extra string) string { + t.Helper() + dir := filepath.Join(repo, filepath.FromSlash(ADRsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := "---\nid: " + id + "\nslug: s\nstatus: " + status + "\ndate: 2026-09-30\n" + extra + "---\n\n# ADR\n" + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +// TestLoadRefusesARepositoryWithoutItsOwnRecord is BX2: the project must hold +// the decision in its own record, and the drain refuses until it does, naming +// how to add it. A store that decides other things is not a drain rule. +func TestLoadRefusesARepositoryWithoutItsOwnRecord(t *testing.T) { + for name, setup := range map[string]func(repo string){ + "no store": func(string) {}, + "no drain fields": func(repo string) { writeADR(t, repo, "0001-other.md", "adr-1", "accepted", "") }, + "only proposed": func(repo string) { writeADR(t, repo, "0002-rule.md", "adr-2", "proposed", strictFields) }, + "only superseded": func(repo string) { writeADR(t, repo, "0003-rule.md", "adr-3", "superseded", strictFields) }, + "not a store file": func(repo string) { writeADR(t, repo, "README.md", "adr-4", "accepted", strictFields) }, + } { + t.Run(name, func(t *testing.T) { + repo := t.TempDir() + setup(repo) + _, err := Load(repo) + if !errors.Is(err, ErrUnrecorded) { + t.Fatalf("got %v, want ErrUnrecorded", err) + } + for _, want := range []string{"ahoy install", FieldCategories, "accepted decision record"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %q: %v", want, err) + } + } + }) + } + // A record carrying the fields but not accepted is named, so its author sees + // why it does not count. + repo := t.TempDir() + writeADR(t, repo, "0002-rule.md", "adr-2", "proposed", strictFields) + if _, err := Load(repo); err == nil || !strings.Contains(err.Error(), "adr-2") || !strings.Contains(err.Error(), "proposed") { + t.Errorf("a proposed record is not named in the refusal: %v", err) + } +} + +// TestLoadReadsTheStrictRecordAsTheBaseline: a record stating the baseline +// loosens nothing, and the rule carries the record's id and path. +func TestLoadReadsTheStrictRecordAsTheBaseline(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "2609291342092738-rule.md", "adr-2609291342092738", "accepted", strictFields) + r, err := Load(repo) + if err != nil { + t.Fatal(err) + } + if r.Record != "adr-2609291342092738" || r.Path != ADRsRelDir+"/2609291342092738-rule.md" { + t.Errorf("record %q at %q", r.Record, r.Path) + } + if len(r.Loosened) != 0 { + t.Errorf("the baseline loosens %v", r.Loosened) + } + b := Baseline() + if !reflect.DeepEqual(r.Categories, b.Categories) || !reflect.DeepEqual(r.Severities, b.Severities) || + r.Security != SecurityHandBack || r.Remedy != RemedyRequired { + t.Errorf("the strict record reads as %+v, want the baseline %+v", r, b) + } +} + +// TestARecordMayNarrowTheFixableSet: a project that takes less than the +// baseline is not loosening anything, and the drain order stays abcd's. +func TestARecordMayNarrowTheFixableSet(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "0007-rule.md", "adr-7", "accepted", + "drain_categories: [ux, documentation]\ndrain_severities: [nitpick]\ndrain_security: handback\ndrain_remedy: required\n") + r, err := Load(repo) + if err != nil { + t.Fatal(err) + } + if strings.Join(r.Categories, ",") != "documentation,ux" || strings.Join(r.Severities, ",") != "nitpick" { + t.Errorf("narrowed rule = %+v", r) + } + if len(r.Loosened) != 0 { + t.Errorf("a narrowed rule loosens %v", r.Loosened) + } + if r.TakesCategory("bug") || !r.TakesCategory("ux") || r.TakesSeverity("minor") { + t.Errorf("the narrowed rule takes the wrong set: %+v", r) + } +} + +// TestALoosenedFloorIsNamed is H11: a project may let a drain take major and +// critical issues and security issues, and every floor it loosens is named, +// measured against abcd's baseline. +func TestALoosenedFloorIsNamed(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "0008-rule.md", "adr-8", "accepted", + "drain_categories: [bug]\ndrain_severities: [critical, minor, major]\ndrain_security: take\ndrain_remedy: required\n") + r, err := Load(repo) + if err != nil { + t.Fatal(err) + } + if want := []string{"severity major", "severity critical", "security"}; !reflect.DeepEqual(r.Loosened, want) { + t.Errorf("loosened = %v, want %v", r.Loosened, want) + } + if strings.Join(r.Severities, ",") != "minor,major,critical" { + t.Errorf("severities in drain order = %v", r.Severities) + } + if strings.Join(r.Categories, ",") != "bug,security" || !r.TakesCategory("security") || !r.TakesSeverity("critical") { + t.Errorf("a record taking security: %+v", r) + } +} + +// TestAMalformedRecordRefuses: a partial or malformed record never falls back +// to a looser rule or to a stricter one without saying so. It refuses, naming +// the field and the record. +func TestAMalformedRecordRefuses(t *testing.T) { + cases := map[string]struct{ fields, want string }{ + "missing remedy": {strings.Replace(strictFields, "drain_remedy: required\n", "", 1), FieldRemedy}, + "missing severities": {strings.Replace(strictFields, "drain_severities: [nitpick, minor]\n", "", 1), FieldSeverities}, + "misspelt key": {strictFields + "drain_severity: [major]\n", "drain_severity"}, + "unknown severity": {strings.Replace(strictFields, "[nitpick, minor]", "[nitpick, small]", 1), "small"}, + "decision category": {strings.Replace(strictFields, "ux]", "ux, process]", 1), "process"}, + "security as a list": {strings.Replace(strictFields, "ux]", "ux, security]", 1), FieldSecurity}, + "unknown category": {strings.Replace(strictFields, "ux]", "ux, chores]", 1), "chores"}, + "empty categories": {strings.Replace(strictFields, "[tech-debt, documentation, inconsistency, drift, bug, ux]", "[]", 1), FieldCategories}, + "block sequence": {strings.Replace(strictFields, "[nitpick, minor]", "\n - nitpick", 1), FieldSeverities}, + "security value": {strings.Replace(strictFields, "handback", "sometimes", 1), "sometimes"}, + "remedy loosened": {strings.Replace(strictFields, "drain_remedy: required", "drain_remedy: optional", 1), FieldRemedy}, + "duplicate key": {strictFields + "drain_security: take\n", "more than once"}, + "quoted list members": {strings.Replace(strictFields, "[nitpick, minor]", `["nitpick", "major"]`, 1), ""}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "0009-rule.md", "adr-9", "accepted", c.fields) + r, err := Load(repo) + if c.want == "" { // a well-formed spelling the reader must accept + if err != nil { + t.Fatalf("a quoted list refused: %v", err) + } + if strings.Join(r.Loosened, ",") != "severity major" { + t.Errorf("loosened = %v", r.Loosened) + } + return + } + if !errors.Is(err, ErrMalformed) { + t.Fatalf("got rule %+v, err %v; want ErrMalformed", r, err) + } + if !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "adr-9") { + t.Errorf("the refusal does not name %q and the record: %v", c.want, err) + } + }) + } +} + +// TestTwoAcceptedRecordsRefuse: which rule an unattended drain applies is never +// a choice the reader makes between two records. +func TestTwoAcceptedRecordsRefuse(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "0010-a.md", "adr-10", "accepted", strictFields) + writeADR(t, repo, "0011-b.md", "adr-11", "accepted", strings.Replace(strictFields, "handback", "take", 1)) + _, err := Load(repo) + if !errors.Is(err, ErrAmbiguous) || !strings.Contains(err.Error(), "adr-10") || !strings.Contains(err.Error(), "adr-11") { + t.Fatalf("got %v, want ErrAmbiguous naming both records", err) + } +} + +// TestLoadNeverReadsThroughALinkOutOfTheCheckout: the rule is committed history +// of the drained tree, so a store that is a symlink leaving the checkout is not +// read, and the drain refuses rather than applying a rule from elsewhere. +func TestLoadNeverReadsThroughALinkOutOfTheCheckout(t *testing.T) { + outside := t.TempDir() + writeADR(t, outside, "0012-rule.md", "adr-12", "accepted", strings.Replace(strictFields, "handback", "take", 1)) + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".abcd", "development", "decisions"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(outside, filepath.FromSlash(ADRsRelDir)), filepath.Join(repo, filepath.FromSlash(ADRsRelDir))); err != nil { + t.Fatal(err) + } + if r, err := Load(repo); err == nil { + t.Fatalf("a rule was read through a link out of the checkout: %+v", r) + } +} + +// TestTheProposalIsTheBaseline: what the setup offer writes reads back as the +// strict baseline, so the offer can never loosen a floor. +func TestTheProposalIsTheBaseline(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "0013-rule.md", "adr-13", "accepted", ProposalFrontmatter()) + r, err := Load(repo) + if err != nil { + t.Fatal(err) + } + b := Baseline() + if len(r.Loosened) != 0 || !reflect.DeepEqual(r.Categories, b.Categories) || !reflect.DeepEqual(r.Severities, b.Severities) { + t.Errorf("the proposal reads as %+v", r) + } + if !strings.Contains(ProposalBody(), "## Decision") || !strings.Contains(ProposalBody(), FieldSeverities) { + t.Errorf("the proposal body does not state the decision and its fields:\n%s", ProposalBody()) + } +} + +// TestTheStoreIsTheDecisionStore pins the store the rule is read from to the +// one `abcd decide` mints into, so the setup offer and the reader agree. +func TestTheStoreIsTheDecisionStore(t *testing.T) { + if ADRsRelDir != decide.ADRsRelDir { + t.Fatalf("drainrule reads %s, decide mints into %s", ADRsRelDir, decide.ADRsRelDir) + } +} diff --git a/internal/core/report/inbox_test.go b/internal/core/report/inbox_test.go index e49834c0c..1169f3d82 100644 --- a/internal/core/report/inbox_test.go +++ b/internal/core/report/inbox_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/drainrule" "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/lint" "github.com/intentdriven/abcd/internal/gittest" @@ -812,6 +813,17 @@ func TestAPromotedReportIsIneligibleForADrain(t *testing.T) { if !strings.Contains(string(body), "Remedy the reporter proposes: accept a leading digit") { t.Errorf("the body lacks the sender's remedy:\n%s", body) } + // The drain reads the checkout's own eligibility record (ruling BX2): the + // baseline, as the setup offer writes it. + adrs := filepath.Join(ledger.Root(), filepath.FromSlash(drainrule.ADRsRelDir)) + if err := os.MkdirAll(adrs, 0o755); err != nil { + t.Fatal(err) + } + rule := "---\nid: adr-2609300000000003\nslug: drain-rule\nstatus: accepted\ndate: 2026-09-30\n" + + drainrule.ProposalFrontmatter() + "---\n\n# ADR\n" + if err := os.WriteFile(filepath.Join(adrs, "2609300000000003-drain-rule.md"), []byte(rule), 0o644); err != nil { + t.Fatal(err) + } plan, err := capture.PlanDrain(capture.DrainPlanRequest{RepoRoot: ledger.Root()}) if err != nil { t.Fatalf("PlanDrain: %v", err) diff --git a/internal/core/surface/sentences.go b/internal/core/surface/sentences.go index ae786647a..7fb9be87b 100644 --- a/internal/core/surface/sentences.go +++ b/internal/core/surface/sentences.go @@ -118,8 +118,8 @@ var sentences = map[string]string{ "abcd docs cite refresh": "Fetch every cited URL once, the one documentation verb that reaches the network: " + "Writes the citation baseline; refuses an unreadable docs-lint configuration.", - "abcd drain": "Sort the open issues by the drain's field rule, eligible first in drain order: " + - "Writes nothing; refuses to start without --dry-run, as the run is not built.", + "abcd drain": "Sort open issues by this repository's own drain rule, naming each loosened floor: " + + "Writes nothing; refuses without the rule's record, or without --dry-run.", "abcd embark": "Unpack a verified lifeboat into a target repository, probing first: " + "Writes only its record families and marker block; refuses the whole write on any conflict.", diff --git a/internal/surface/cli/barerender.go b/internal/surface/cli/barerender.go index 36b2f4900..602a505bb 100644 --- a/internal/surface/cli/barerender.go +++ b/internal/surface/cli/barerender.go @@ -20,8 +20,8 @@ var bareRenderExceptions = map[string]string{ "disembark": "a parent of stage sub-verbs that each act on a named repository or " + "lifeboat; with no operand there is no state to render, so bare prints its sub-verbs", "drain": "bare is the run itself, which is not built, so bare refuses to start " + - "(exit 2) naming the missing lane and writes nothing; what a drain would do " + - "renders with --dry-run", + "(exit 2) naming the missing lane, or the repository's missing eligibility record, " + + "and writes nothing; what a drain would do renders with --dry-run", "docs": "a parent holding the citation-baseline writer alone; the documentation's " + "state is `abcd lint docs`, so bare prints its sub-verb", "embark": "a parent whose sub-verbs act on a named lifeboat; with no operand there " + diff --git a/internal/surface/cli/capture_remedy_required_test.go b/internal/surface/cli/capture_remedy_required_test.go index a828189ca..44fe85d7a 100644 --- a/internal/surface/cli/capture_remedy_required_test.go +++ b/internal/surface/cli/capture_remedy_required_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/drainrule" "github.com/intentdriven/abcd/internal/core/issueschema" ) @@ -61,7 +62,7 @@ func TestCaptureRefusesTheMachineRemedyFromAPerson(t *testing.T) { // remedy` writes the person's fix, says what it replaced, and the dry run then // lists the record as eligible. func TestCaptureRemedyVerbWritesTheRemedyAndTheDrainTakesIt(t *testing.T) { - repo := captureLedgerRepo(t) + repo := drainRuleRepo(t, drainrule.ProposalFrontmatter()) res, err := capture.Capture(capture.CaptureRequest{ RepoRoot: repo, Text: "a nil map is written before it is made", Severity: "minor", Category: "bug", Source: "agent-finding", FoundDuring: "t", Remedy: issueschema.MachineRemedy, diff --git a/internal/surface/cli/drain.go b/internal/surface/cli/drain.go index be0c9a890..b3a554f37 100644 --- a/internal/surface/cli/drain.go +++ b/internal/surface/cli/drain.go @@ -1,11 +1,13 @@ package cli import ( + "errors" "fmt" "io" "strings" "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/drainrule" "github.com/intentdriven/abcd/internal/termsafe" "github.com/spf13/cobra" ) @@ -26,35 +28,48 @@ func newDrainCommand(asJSON *bool) *cobra.Command { cmd := &cobra.Command{ Use: "drain", Long: "Work the open issue ledger unattended: fix the issues that need no decision, and\n" + - "hand the rest back by kind. The rule for which issues need no decision is a\n" + - "recorded decision, and it reads the record's fields alone: nothing open in\n" + - "blocked_by; a category in the fixable set (tech-debt, documentation,\n" + - "inconsistency, drift, bug, ux); severity nitpick or minor; and a remedy: field.\n" + - "A security issue is always a person's. Every other open issue is handed back,\n" + - "listed as ineligible, or skipped naming its blocker, by the rule that excluded it.\n\n" + + "hand the rest back by kind. Which issues need no decision is this repository's own\n" + + "recorded decision: an accepted decision record whose frontmatter carries the four\n" + + "fields drain_categories, drain_severities, drain_security and drain_remedy. The\n" + + "rule reads the record's fields alone: nothing open in blocked_by; a category the\n" + + "rule takes; a severity it takes; and a remedy: field. abcd's strict baseline takes\n" + + "tech-debt, documentation, inconsistency, drift, bug and ux at nitpick or minor, and\n" + + "hands every security issue to a person. A repository's record may loosen those\n" + + "floors (major, critical, security), and every floor it loosens is named. An issue\n" + + "whose remedy opens \"Waits on\", or whose deferral past the current release tag is\n" + + "live, is always handed back. Every other open issue is handed back, listed as\n" + + "ineligible, or skipped naming its blocker, by the rule that excluded it.\n\n" + "--dry-run shows every open issue's disposition, the eligible ones first in the\n" + - "order a drain takes them (category tech-debt, documentation, inconsistency,\n" + - "drift, bug, ux; then nitpick before minor; then oldest first), and writes\n" + - "nothing. The host judgement over each eligible remedy does not run in a dry\n" + + "order a drain takes them (by category, then severity, then oldest first), and\n" + + "writes nothing. The host judgement over each eligible remedy does not run in a dry\n" + "run; it can only ever hand an issue back.\n\n" + - "The run itself is not built: without --dry-run the verb refuses to start, and\n" + - "exits 2 with nothing read or written.", + "Without the repository's record, the dry run and the run both refuse (exit 2),\n" + + "naming how to add it; `abcd ahoy install` offers it. The run itself is not built:\n" + + "without --dry-run the verb refuses to start, and exits 2 with nothing written.", Example: " abcd drain --dry-run\n abcd drain --dry-run --json", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if !dryRun { - if err := capture.DrainStart(); err != nil { - return &exitError{Code: 2, Msg: "abcd drain: refused to start: " + err.Error() + " (nothing read, nothing written)"} - } - } repoRoot, err := ledgerRootFor(cmd, "abcd drain") if err != nil { return err } + if !dryRun { + err := capture.DrainStart(repoRoot) + return &exitError{Code: 2, Msg: "abcd drain: refused to start: " + termsafe.Sanitize(err.Error()) + " (nothing written)"} + } plan, err := capture.PlanDrain(capture.DrainPlanRequest{RepoRoot: repoRoot}) + if errors.Is(err, drainrule.ErrUnrecorded) || errors.Is(err, drainrule.ErrMalformed) || errors.Is(err, drainrule.ErrAmbiguous) { + return &exitError{Code: 2, Msg: "abcd drain: " + termsafe.Sanitize(err.Error()) + " (nothing written)"} + } if err != nil { return fmt.Errorf("abcd drain: %w", err) } + // A loosened floor is loud (ruling H11): named on stderr whatever the + // output mode, so a reader of --json sees it too. + if len(plan.Loosened) > 0 { + fmt.Fprintf(cmd.ErrOrStderr(), "abcd drain: warning: this repository's rule (%s) loosens abcd's floors: a drain may take %s\n", + termsafe.Sanitize(plan.Record), termsafe.Sanitize(strings.Join(plan.Loosened, ", "))) + } out := drainOutput{DryRun: true, DrainPlan: plan} return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, out, func(w io.Writer) { renderDrainPlan(w, plan) }) }, @@ -73,7 +88,18 @@ var drainOutcomes = []capture.DrainOutcome{ // line per open issue, and the counts. Every runtime string is sanitised. func renderDrainPlan(w io.Writer, plan capture.DrainPlan) { fmt.Fprintf(w, "abcd drain --dry-run: %d open issue(s) classified by field; writes nothing\n", len(plan.Dispositions)) - fmt.Fprintf(w, " rule: %s (which issues need no decision)\n", termsafe.Sanitize(plan.Record)) + fmt.Fprintf(w, " rule: %s, this repository's own record (which issues need no decision)\n", termsafe.Sanitize(plan.Record)) + if len(plan.Loosened) == 0 { + fmt.Fprintln(w, " floors: the rule loosens none of abcd's floors") + } else { + fmt.Fprintln(w, " LOOSENED: this repository's rule lets a drain take what abcd's baseline hands to a person:") + for _, f := range plan.Loosened { + fmt.Fprintf(w, " - %s\n", termsafe.Sanitize(f)) + } + } + if plan.Anchor != "" { + fmt.Fprintf(w, " anchor: %s (a deferral past it is live)\n", termsafe.Sanitize(plan.Anchor)) + } fmt.Fprintf(w, " order: %s\n", termsafe.Sanitize(plan.Order)) for _, v := range plan.Dispositions { kind := strings.TrimSpace(string(v.Severity) + " " + string(v.Category)) diff --git a/internal/surface/cli/drain_surface_test.go b/internal/surface/cli/drain_surface_test.go index 2000cb2dc..025ef8719 100644 --- a/internal/surface/cli/drain_surface_test.go +++ b/internal/surface/cli/drain_surface_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/intentdriven/abcd/internal/core/drainrule" ) // The front doors of the drain's field-only slice (itd-82, @@ -52,7 +54,7 @@ func TestCaptureRemedyFlagWritesTheField(t *testing.T) { // each open issue once with its disposition and rule, states the order and the // rule's record, in text and in --json, and leaves the ledger as it was. func TestDrainDryRunRendersEveryDispositionAndWritesNothing(t *testing.T) { - repo := captureLedgerRepo(t) + repo := drainRuleRepo(t, drainrule.ProposalFrontmatter()) eligible := captureWithRemedy(t, "a nil map is written before it is made", "--category", "bug", "--remedy", "make the map first") // A legacy record: filed before the remedy was required, so it carries // none. capture refuses such a filing now (ruling BX3), so the key is taken @@ -106,7 +108,7 @@ func TestDrainDryRunRendersEveryDispositionAndWritesNothing(t *testing.T) { // TestDrainWithoutDryRunRefusesToStart: the run itself is not built, so a bare // `drain` refuses, says why, points at the dry run, and writes nothing. func TestDrainWithoutDryRunRefusesToStart(t *testing.T) { - repo := captureLedgerRepo(t) + repo := drainRuleRepo(t, drainrule.ProposalFrontmatter()) captureWithRemedy(t, "a nil map is written before it is made", "--category", "bug", "--remedy", "make the map first") before := ledgerIssueCount(t, repo) @@ -148,3 +150,118 @@ func stripRemedyLine(t *testing.T, repo, id string) { t.Fatal(err) } } + +// drainRuleRepo is a ledger checkout holding its own drain eligibility record +// (ruling BX2), an accepted decision record carrying the drain fields given. +func drainRuleRepo(t *testing.T, fields string) string { + t.Helper() + repo := captureLedgerRepo(t) + dir := filepath.Join(repo, filepath.FromSlash(drainrule.ADRsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := "---\nid: adr-2609300000000002\nslug: drain-rule\nstatus: accepted\ndate: 2026-09-30\n" + fields + "---\n\n# ADR\n" + if err := os.WriteFile(filepath.Join(dir, "2609300000000002-drain-rule.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return repo +} + +// TestDrainRefusesARepositoryWithoutItsOwnRule is ruling BX2 at the front door: +// with no eligibility record of the repository's own, the dry run and the bare +// verb both refuse (exit 2), name how to add the record, and write nothing. +func TestDrainRefusesARepositoryWithoutItsOwnRule(t *testing.T) { + repo := captureLedgerRepo(t) + captureWithRemedy(t, "a nil map is written before it is made", "--category", "bug", "--remedy", "make the map first") + before := ledgerIssueCount(t, repo) + for _, args := range [][]string{{"drain", "--dry-run"}, {"drain", "--dry-run", "--json"}, {"drain"}} { + var stdout, stderr bytes.Buffer + code := Run(args, &stdout, &stderr) + msg := stdout.String() + stderr.String() + if code != 2 { + t.Fatalf("%v exited %d, want 2:\n%s", args, code, msg) + } + for _, want := range []string{"no drain eligibility record", "ahoy install", drainrule.FieldCategories, "nothing written"} { + if !strings.Contains(msg, want) { + t.Errorf("%v: the refusal does not say %q:\n%s", args, want, msg) + } + } + } + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(drainrule.ADRsRelDir))); !os.IsNotExist(err) { + t.Errorf("a refused drain created the decision store: %v", err) + } + if after := ledgerIssueCount(t, repo); after != before { + t.Fatalf("a refused drain changed the ledger") + } +} + +// TestDrainNamesEveryLoosenedFloor is ruling H11 at the front door: a project +// whose record lets a drain take major and security issues has every loosened +// floor named by the dry run's text, on stderr, in --json, and by the start's +// refusal. +func TestDrainNamesEveryLoosenedFloor(t *testing.T) { + drainRuleRepo(t, "drain_categories: [tech-debt, documentation, inconsistency, drift, bug, ux]\n"+ + "drain_severities: [nitpick, minor, major]\ndrain_security: take\ndrain_remedy: required\n") + major := captureWithRemedy(t, "the parser drops a whole record", "--category", "bug", "--severity", "major", "--remedy", "rewrite it") + + var stdout, stderr bytes.Buffer + if code := Run([]string{"drain", "--dry-run"}, &stdout, &stderr); code != 0 { + t.Fatalf("dry run exited %d:\n%s%s", code, stdout.String(), stderr.String()) + } + for _, want := range []string{"LOOSENED", "severity major", "security", "adr-2609300000000002", major} { + if !strings.Contains(stdout.String(), want) { + t.Errorf("the dry-run text does not carry %q:\n%s", want, stdout.String()) + } + } + if !strings.Contains(stderr.String(), "loosens abcd's floors") || !strings.Contains(stderr.String(), "severity major, security") { + t.Errorf("stderr does not warn of the loosened floors:\n%s", stderr.String()) + } + + stdout.Reset() + stderr.Reset() + if code := Run([]string{"drain", "--dry-run", "--json"}, &stdout, &stderr); code != 0 { + t.Fatalf("--json dry run exited %d:\n%s%s", code, stdout.String(), stderr.String()) + } + var plan struct { + Loosened []string `json:"loosened"` + Rule struct { + Record string `json:"record"` + Severities []string `json:"severities"` + Security string `json:"security"` + } `json:"rule"` + } + if err := json.Unmarshal(stdout.Bytes(), &plan); err != nil { + t.Fatalf("--json is not JSON: %v\n%s", err, stdout.String()) + } + if strings.Join(plan.Loosened, ",") != "severity major,security" || plan.Rule.Security != "take" || plan.Rule.Record == "" { + t.Errorf("--json plan = %+v", plan) + } + if !strings.Contains(stderr.String(), "loosens abcd's floors") { + t.Errorf("--json does not warn of the loosened floors on stderr:\n%s", stderr.String()) + } + + stdout.Reset() + stderr.Reset() + code := Run([]string{"drain"}, &stdout, &stderr) + msg := stdout.String() + stderr.String() + if code != 2 || !strings.Contains(msg, "loosens abcd's floors") || !strings.Contains(msg, "severity major, security") { + t.Errorf("the start (exit %d) does not name the loosened floors:\n%s", code, msg) + } +} + +// TestDrainOnTheStrictRuleNamesNoLoosening: the baseline says so in one line, +// and warns of nothing. +func TestDrainOnTheStrictRuleNamesNoLoosening(t *testing.T) { + drainRuleRepo(t, drainrule.ProposalFrontmatter()) + captureWithRemedy(t, "a nil map is written before it is made", "--category", "bug", "--remedy", "make the map first") + var stdout, stderr bytes.Buffer + if code := Run([]string{"drain", "--dry-run"}, &stdout, &stderr); code != 0 { + t.Fatalf("dry run exited %d:\n%s%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "loosens none of abcd's floors") || strings.Contains(stdout.String(), "LOOSENED") { + t.Errorf("the strict rule's dry run:\n%s", stdout.String()) + } + if strings.Contains(stderr.String(), "loosens") { + t.Errorf("the strict rule warns on stderr:\n%s", stderr.String()) + } +} From a101c59d1263d9c512597f1d590dd6d5de27faea Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:50:39 +0100 Subject: [PATCH 11/69] feat(ahoy): offer the drain eligibility record at setup, written only on the person's yes Ruling BX2 (2026-09-29), verbatim: "the PROJECT MUST HOLD the eligibility decision in its own record (e.g. added at setup); drain refuses there until it does". Setup is where it is added. `ahoy install` raises an optional repository gap, drain_rule.offered, in its own category, drain-rule, asked after oracle-routing, while the repository's decision store holds no accepted record carrying the drain fields. A record that states the rule badly is not offered a second one; the drain names what is wrong with the one it has. The offer states abcd's strict baseline in one question, and a yes mints it through the decision store's own seam (new decide.CreateStated: same id, date and filename allocator as `abcd decide`, written accepted, since the person's yes is the decision) with the four drain_ fields and the four sections. It re-reads the store before writing, so a record that appeared while the question was open is never doubled. Consistent with the routing offers: --yes approves the category but never writes the record, because it decides what an unattended agent may change in the repository, and reports it under optional_skipped with that reason; a decline writes and records nothing, so the next install offers again; the offer only ever writes the baseline, and loosening is an edit a person makes to the record. Piped answer streams gain one question, drain-rule, between oracle-routing and user-state; commands/ahoy.md names the order. Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/01-ahoy.md | 1 + commands/ahoy.md | 26 ++- internal/core/ahoy/ahoy.go | 4 + internal/core/ahoy/apply.go | 10 +- internal/core/ahoy/detect.go | 1 + internal/core/ahoy/drain_rule.go | 99 ++++++++++++ internal/core/ahoy/drain_rule_test.go | 148 ++++++++++++++++++ internal/core/ahoy/install_summary.go | 18 ++- internal/core/ahoy/oracle_routing_test.go | 2 +- internal/core/ahoy/prompt_order_test.go | 10 +- internal/core/decide/decide.go | 36 ++++- internal/core/decide/decide_test.go | 24 +++ .../surface/cli/ahoy_prompt_stdin_test.go | 9 +- internal/surface/cli/cli.go | 2 + 14 files changed, 375 insertions(+), 15 deletions(-) create mode 100644 internal/core/ahoy/drain_rule.go create mode 100644 internal/core/ahoy/drain_rule_test.go diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index 532e07d65..4795ac2b8 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -466,6 +466,7 @@ about, one question per category present, never one per item. | `dependency` | a tool a capability uses and cannot find: gitleaks, optional over the native secret scanner and required where the repository armed it in `.abcd/config/gitleaks.json` | the category approval reaches the step; each tool is then explained from the tool registry (what it is, optional or required here, what works without it, the exact install step, what the install does) and its install step runs only on a per-tool yes — typed at a terminal, or relayed by a host as a flag naming the tool — never under the approve-everything flag, a piped answer or CI; a no is reported as what the capability continues on | | `status-line` | the offer of abcd's status line in the host harness | an advisory offer asked after its own question, written only on an answered consent; never under the approve-everything flag, and reported as optional work it skipped | | `oracle-routing` | the offer of abcd's proposed model-tier routing table (itd-2609170822093401): the machine's `~/.abcd/oracle-routing.json`, then, as a separate question, the repository's `.abcd/config/oracle-routing.json` | the proposal rendered as a table (agent, tier, fan-out) and each file written only on its own answered consent, the machine one owner-only; never under the approve-everything flag, and reported as optional work it skipped; a decline records nothing, so the next install offers again; uninstall leaves both files | +| `drain-rule` | the offer of the repository's drain eligibility record (ruling BX2, itd-82): abcd's strict baseline as an accepted decision record carrying the four `drain_` fields, minted through the decision store's seam | the rule stated in one question and the record written only on an answered consent; never under the approve-everything flag, and reported as optional work it skipped; a decline records nothing, so the next install offers again; raised only while no accepted record states the rule, so a record stating it badly is never offered a second; only ever the baseline, never a loosened rule | | `user-state` | the registry entry, re-founding, stale or duplicate entries | guided; never auto-edit user-scope state, report extras read-only | **The artefact kind is a gap until it is declared** (itd-2609150819432059). A diff --git a/commands/ahoy.md b/commands/ahoy.md index 4a0205ffd..f8f01f7d4 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -147,7 +147,7 @@ category present — often several — and every line after the last one you sup reads end-of-input and DECLINES. `yes` is the reliable form because it never runs out; a single `printf 'y\n'` answers the first question only and silently declines the rest. The questions come in a fixed order (dependency, -safe-autocreate, config-change, status-line, oracle-routing, user-state, plugin-owned), so a +safe-autocreate, config-change, status-line, oracle-routing, drain-rule, user-state, plugin-owned), so a scripted stream of specific answers lines up with them. Each answer is echoed back, so the transcript shows what was asked and what it was answered — read it back rather than assuming. Under `set -o pipefail` the pipeline reports 141: `yes` takes @@ -184,8 +184,10 @@ that must not block and must not prompt, close stdin or pre-answer everything: `--yes` approves every resolvable category but never adopts the optional git-identity pin, because the pin records whatever git identity is currently configured, never wires the status line (below), because that rewrites a -harness-wide setting, and never accepts a model-tier routing table (below), -because a table decides which model every delegated step asks for. When the result carries `optional_skipped`, report it and +harness-wide setting, never accepts a model-tier routing table (below), +because a table decides which model every delegated step asks for, and never +adds the drain eligibility record (below), because the record decides what an +unattended agent may change in the repository. When the result carries `optional_skipped`, report it and offer the `yes |` form above as the way to apply it. **The git identity question is a person's alone.** When the author or committer @@ -298,6 +300,24 @@ from. With no provider configured every step still runs through the harness, which is asked for the tier. `ahoy uninstall` leaves both files, because they are the user's configuration. +**The drain eligibility record offer.** `abcd drain` takes an open issue alone +only under a rule the repository records for itself, and refuses to run until an +accepted decision record in `.abcd/development/decisions/adrs/` states it in +four frontmatter fields (`drain_categories`, `drain_severities`, +`drain_security`, `drain_remedy`). While no accepted record carries them, the +install states abcd's strict baseline in one question (take an issue only when +its category is `tech-debt`, `documentation`, `inconsistency`, `drift`, `bug` or +`ux`, its severity is `nitpick` or `minor`, it carries a remedy and nothing open +blocks it; every security, major and critical issue is a person's); consent +mints it through the decision store's own seam as an accepted record, which is +committed with the repository. Relay the user's answer; never answer it for the +user. Declining writes nothing and records nothing, so the next install offers +again; `--yes` skips the offer and reports `drain_rule.offered` under +`optional_skipped`; `yes |` accepts it. The offer only ever writes the baseline: +loosening a floor is an edit a person makes to the record, and `abcd drain` +names every floor loosened. A repository whose record states the rule badly is +not offered a second one; `abcd drain` names what is wrong with the one it has. + `--attribution` is its own approval and works on an already-installed repo (the step the adopt phase runs it in). It opts the repo into the committed `prepare-commit-msg` prompt, diff --git a/internal/core/ahoy/ahoy.go b/internal/core/ahoy/ahoy.go index 4773e5374..f0437468c 100644 --- a/internal/core/ahoy/ahoy.go +++ b/internal/core/ahoy/ahoy.go @@ -56,6 +56,10 @@ const ( // --yes: a routing table decides which model every delegated step asks // for, so only an answered prompt accepts one. OracleRouting GapCategory = "oracle-routing" + // DrainRule (drain_rule.go) covers adding the repository's drain + // eligibility record. Its gap is advisory and never written under --yes: + // the record decides what an unattended agent may do in the repository, so + // only an answered prompt adds one. ) // Gap is one detected discrepancy between desired and actual state. diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index e46d14141..c2107c25e 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -185,6 +185,8 @@ func install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) ac.stepStatusLine() // After the status line, the order the consent questions are asked in. ac.stepOracleRouting() + // After the routing offers: the last of the repository consent questions. + ac.stepDrainRule() ac.stepRules() ac.stepVersionStamp() // Before the pin: an identity mended here is the one the pin then records. @@ -1777,9 +1779,10 @@ const credentialAtRestGapID = "history.credential_at_rest" // optionalGapIDs are the advisory gaps install closes only against an answered // prompt, never under --yes: the identity pin (see stepIdentityPin), the -// status-line offer (see stepStatusLine) and the two model-tier routing offers -// (see stepOracleRouting). In the order they are reported. -var optionalGapIDs = []string{OptionalPinGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID} +// status-line offer (see stepStatusLine), the two model-tier routing offers +// (see stepOracleRouting) and the drain eligibility record (see +// stepDrainRule). In the order they are reported. +var optionalGapIDs = []string{OptionalPinGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID, DrainRuleOfferGapID} // optionalSkipped lists the optional gaps a --yes run left un-applied. --yes // approves every resolvable category but never adopts the identity pin or @@ -1854,6 +1857,7 @@ var categoryPromptOrder = []GapCategory{ ConfigChange, StatusLine, OracleRouting, + DrainRule, UserState, PluginOwned, } diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index 42a010106..bf809a179 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -105,6 +105,7 @@ func Detect(cwd string) (DetectionResult, error) { gaps = append(gaps, detectPathSymlink(abs, pluginRoot, pluginOK)...) gaps = append(gaps, detectStatusLine(harness)...) gaps = append(gaps, detectOracleRouting(abs)...) + gaps = append(gaps, detectDrainRule(abs)...) gaps = append(gaps, detectProviderAdapter(abs)...) gaps = append(gaps, detectHookManifest(pluginRoot, pluginOK)...) gaps = append(gaps, detectVersion(abs)...) diff --git a/internal/core/ahoy/drain_rule.go b/internal/core/ahoy/drain_rule.go new file mode 100644 index 000000000..6e32992c9 --- /dev/null +++ b/internal/core/ahoy/drain_rule.go @@ -0,0 +1,99 @@ +package ahoy + +// The drain eligibility record `ahoy install` offers (the product thinker's +// ruling BX2 of 2026-09-29, verbatim: "the PROJECT MUST HOLD the eligibility +// decision in its own record (e.g. added at setup); drain refuses there until +// it does"). `abcd drain` reads which open issues it may take alone from an +// accepted decision record in the repository's own store (core/drainrule), and +// refuses a repository without one. Setup is where one is added: +// +// - detection raises an optional repository gap while the store holds no +// accepted record carrying the drain fields; a record that states the rule +// badly is not offered a second one, since the drain names what is wrong +// with the record it has; +// - the offer states abcd's strict baseline and, on the person's yes, mints +// it through the decision store's own seam as an accepted record, which is +// committed with the repository and decides for everyone who drains it. +// +// Like the routing offers, it is asked only at an answered prompt: --yes +// approves the category but never writes the record, because the record +// decides what an unattended agent may do in this repository, and it is +// reported under optional_skipped. A decline writes nothing and records +// nothing, so the next install offers again. The offer only ever writes the +// baseline; loosening a floor is an edit a person makes to the record. + +import ( + "errors" + "path/filepath" + "strings" + + "github.com/intentdriven/abcd/internal/core/decide" + "github.com/intentdriven/abcd/internal/core/drainrule" +) + +// DrainRule covers adding the repository's drain eligibility record. +const DrainRule GapCategory = "drain-rule" + +// DrainRuleOfferGapID is the offer of the drain eligibility record. +const DrainRuleOfferGapID = "drain_rule.offered" + +// drainRuleQuestionTail ends the offer, so a scripted answer stream and the +// transcript can tell it apart. +const drainRuleQuestionTail = "Add this rule to this repository's decision records?" + +// detectDrainRule raises the offer while the repository holds no record of the +// rule. +func detectDrainRule(cwd string) []Gap { + if _, err := drainrule.Load(cwd); !errors.Is(err, drainrule.ErrUnrecorded) { + return nil + } + return []Gap{{ + ID: DrainRuleOfferGapID, Category: DrainRule, Scope: "repo", + Title: "no drain eligibility record in this repository", + Detail: "abcd drain takes an open issue alone only under this repository's own recorded rule, and refuses " + + "to run until an accepted decision record states it.", + FixHint: "ahoy install offers abcd's strict baseline as an accepted decision record and writes it only on " + + "consent; --yes never accepts it. Or add the four drain_ fields to an accepted record by hand.", + Required: false, Resolvable: true, + }} +} + +// stepDrainRule makes the offer. It never runs under --yes. +func (a *applyCtx) stepDrainRule() { + if a.autoYes || !a.approved[DrainRule] || !a.has(DrainRuleOfferGapID) { + return + } + if !a.prompter.Confirm(drainRuleQuestion()) { + return + } + // Re-read at the moment of writing: a record that appeared while the + // question was open is the repository's rule, and a second is never added. + if _, err := drainrule.Load(a.cwd); !errors.Is(err, drainrule.ErrUnrecorded) { + a.refuse("the drain eligibility record was not written: the repository's decision store changed while the question was open, and is left as it is.") + return + } + d, err := decide.CreateStated(a.cwd, decide.Stated{ + Title: drainrule.ProposalTitle, + Frontmatter: drainrule.ProposalFrontmatter(), + Body: drainrule.ProposalBody(), + }) + if err != nil { + a.refuse("could not write the drain eligibility record (" + errText(err) + "); nothing was written.") + return + } + a.note(writeDrainRule, filepath.Join(a.cwd, filepath.FromSlash(d.Path))) +} + +// drainRuleQuestion is the reason, the rule and the question: core never +// prints, so the rule travels as the text of the confirm. +func drainRuleQuestion() string { + b := drainrule.Baseline() + return "abcd drain works the open issue ledger unattended, and takes an issue alone only under a rule this " + + "repository records for itself; until it does, the drain refuses to run here. abcd's strict baseline: " + + "take an issue only when its category is one of " + strings.Join(b.Categories, ", ") + + ", its severity is " + strings.Join(b.Severities, " or ") + + ", it carries a remedy, and nothing open blocks it; every security issue, and every major or critical " + + "one, is a person's. Accepting writes this rule as an accepted decision record under " + + drainrule.ADRsRelDir + "/, committed with the repository, where it can be edited; loosening it is " + + "named on every drain. Declining writes nothing. " + drainRuleQuestionTail +} diff --git a/internal/core/ahoy/drain_rule_test.go b/internal/core/ahoy/drain_rule_test.go new file mode 100644 index 000000000..bf739f31f --- /dev/null +++ b/internal/core/ahoy/drain_rule_test.go @@ -0,0 +1,148 @@ +package ahoy + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/drainrule" +) + +// The setup offer of the drain eligibility record (ruling BX2: "the PROJECT +// MUST HOLD the eligibility decision in its own record (e.g. added at setup); +// drain refuses there until it does"). The offer writes abcd's strict baseline +// as an accepted decision record, and only on the person's own yes. + +// drainRulePrompter approves every category question and answers the drain +// rule offer as told; every other offer is declined. +func drainRulePrompter(yes bool) *scriptedPrompter { + return &scriptedPrompter{confirm: func(q string) bool { + switch { + case strings.HasPrefix(q, "Apply "): + return true + case strings.Contains(q, drainRuleQuestionTail): + return yes + } + return false + }} +} + +// TestDrainRuleOfferWritesTheBaselineOnYes: a managed repository without the +// record is offered it, the question states the rule, and a yes writes an +// accepted record that the drain's own reader reads as the strict baseline, +// after which the offer is not made again. +func TestDrainRuleOfferWritesTheBaselineOnYes(t *testing.T) { + setupHermetic(t) + repo := installedRepo(t) + det, err := Detect(repo) + if err != nil { + t.Fatal(err) + } + if !hasGap(det.Gaps, DrainRuleOfferGapID) { + t.Fatalf("a repository without the record is not offered it: gaps %v", det.Gaps) + } + + p := drainRulePrompter(true) + res, err := Install(repo, InstallOptions{}, p) + if err != nil { + t.Fatal(err) + } + asked := false + for _, q := range p.asked { + if strings.Contains(q, drainRuleQuestionTail) { + asked = true + for _, want := range []string{"nitpick", "minor", "security", "remedy", "abcd drain"} { + if !strings.Contains(q, want) { + t.Errorf("the offer does not state %q:\n%s", want, q) + } + } + } + } + if !asked { + t.Fatalf("the drain rule was not offered; asked %q", p.asked) + } + r, err := drainrule.Load(repo) + if err != nil { + t.Fatalf("the written record does not load: %v (writes %v, notes %v)", err, res.Writes, res.Notes) + } + if len(r.Loosened) != 0 || r.Security != drainrule.SecurityHandBack || strings.Join(r.Severities, ",") != "nitpick,minor" { + t.Errorf("the offer wrote a rule that is not the baseline: %+v", r) + } + raw, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(r.Path))) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "\nstatus: accepted\n") || !strings.Contains(string(raw), "## Decision") { + t.Errorf("the record is not an accepted decision record:\n%s", raw) + } + if !containsString(res.Writes, r.Path) { + t.Errorf("the write is not reported: %v", res.Writes) + } + det, _ = Detect(repo) + if hasGap(det.Gaps, DrainRuleOfferGapID) { + t.Error("a repository holding the record is offered it again") + } +} + +// TestDrainRuleOfferDeclineWritesNothing: a no writes nothing and is not +// persisted, so the next install offers again. +func TestDrainRuleOfferDeclineWritesNothing(t *testing.T) { + setupHermetic(t) + repo := installedRepo(t) + if _, err := Install(repo, InstallOptions{}, drainRulePrompter(false)); err != nil { + t.Fatal(err) + } + if _, err := drainrule.Load(repo); !errors.Is(err, drainrule.ErrUnrecorded) { + t.Fatalf("a declined offer left a rule: %v", err) + } + det, _ := Detect(repo) + if !hasGap(det.Gaps, DrainRuleOfferGapID) { + t.Error("a decline was persisted; the offer must stand for the next install") + } +} + +// TestDrainRuleOfferYesFlagSkipsAndSaysSo: --yes approves the category but +// never writes the record, which decides what an unattended agent may do, and +// names it under optional_skipped, as the other consent offers are. +func TestDrainRuleOfferYesFlagSkipsAndSaysSo(t *testing.T) { + setupHermetic(t) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + res, err := Install(repo, installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if !containsString(res.OptionalSkipped, DrainRuleOfferGapID) { + t.Errorf("optional_skipped = %v, want it to name %s", res.OptionalSkipped, DrainRuleOfferGapID) + } + if _, err := drainrule.Load(repo); !errors.Is(err, drainrule.ErrUnrecorded) { + t.Errorf("--yes wrote a drain rule: %v", err) + } +} + +// TestDrainRuleOfferLeavesAMalformedRecordAlone: a repository that states a +// rule, however badly, is not offered a second one; the drain names what is +// wrong with the record it has. +func TestDrainRuleOfferLeavesAMalformedRecordAlone(t *testing.T) { + setupHermetic(t) + repo := installedRepo(t) + dir := filepath.Join(repo, filepath.FromSlash(drainrule.ADRsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := "---\nid: adr-7\nstatus: accepted\ndrain_categories: [bug]\n---\n" + if err := os.WriteFile(filepath.Join(dir, "0007-rule.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + det, err := Detect(repo) + if err != nil { + t.Fatal(err) + } + if hasGap(det.Gaps, DrainRuleOfferGapID) { + t.Error("a repository stating a malformed rule is offered a second record") + } +} diff --git a/internal/core/ahoy/install_summary.go b/internal/core/ahoy/install_summary.go index c23d529a5..d82bb19b6 100644 --- a/internal/core/ahoy/install_summary.go +++ b/internal/core/ahoy/install_summary.go @@ -33,6 +33,7 @@ const ( writeCommandEntry writeKind = "command-entry" writeStatusLine writeKind = "status-line" writeRouting writeKind = "routing" + writeDrainRule writeKind = "drain-rule" writeRules writeKind = "rules" writeIdentityPin writeKind = "identity-pin" writeGitIdentity writeKind = "git-identity" @@ -45,7 +46,7 @@ var allWriteKinds = []writeKind{ writeSettings, writeGitignore, writeLocalTier, writeNameGuard, writePrivateNames, writeDocsCheck, writeAttributionHook, writeRules, writeConventionsBlock, writeConventionsBlockRemoved, writeGitIdentity, writeIdentityPin, writeArtefactKind, writeCommandEntry, writeSessionStore, - writeStatusLine, writeRouting, + writeStatusLine, writeRouting, writeDrainRule, } // writeKindHelp is the plain-language explanation of each kind of write. @@ -135,6 +136,11 @@ var writeKindHelp = map[writeKind]SummaryItem{ Why: "Larger models cost more; the table keeps the expensive ones for the steps that need them.", Action: "Nothing. Edit the saved table if you want a different split.", }, + writeDrainRule: { + What: "Recorded which open issues abcd drain may fix without asking you, as a decision record in this repository.", + Why: "abcd drain refuses to run in a repository until it records that rule; this one is abcd's strict default.", + Action: "Commit the record. Edit its drain_ fields to change the rule; abcd drain names any loosening.", + }, } // unexplainedWriteHelp is what a write reaches the person as when it carries no @@ -185,6 +191,11 @@ var declinedCategoryHelp = map[GapCategory]SummaryItem{ Why: "Review steps use whatever model your assistant picks by default.", Action: "Nothing. Run abcd ahoy install again if you want the table.", }, + DrainRule: { + What: "You declined recording which open issues abcd drain may fix without asking you.", + Why: "abcd drain refuses to run in this repository until the rule is recorded.", + Action: "Nothing, unless you want to drain; then run abcd ahoy install again and answer y.", + }, } // optionalSkippedHelp explains each optional step an unattended run left alone. @@ -209,6 +220,11 @@ var optionalSkippedHelp = map[string]SummaryItem{ Why: "The table decides which model, and so what cost, each review step asks for, so it needs your own yes.", Action: "Run abcd ahoy install without --yes and answer the question about the table.", }, + DrainRuleOfferGapID: { + What: "The rule for which open issues abcd drain may fix without asking you was not recorded.", + Why: "The rule decides what an unattended agent may change in this repository, so it needs your own yes; until it is recorded, abcd drain refuses to run here.", + Action: "Run abcd ahoy install without --yes and answer the question about the drain rule.", + }, } // remainingHelp explains the required work a run left outstanding. diff --git a/internal/core/ahoy/oracle_routing_test.go b/internal/core/ahoy/oracle_routing_test.go index 4b3bfe51e..f9f237c10 100644 --- a/internal/core/ahoy/oracle_routing_test.go +++ b/internal/core/ahoy/oracle_routing_test.go @@ -197,7 +197,7 @@ func TestUninstallLeavesTheRoutingTables(t *testing.T) { // TestOracleRoutingIsAskedAfterTheStatusLine pins the consent order the spec // names: …, status-line, oracle-routing, user-state, … func TestOracleRoutingIsAskedAfterTheStatusLine(t *testing.T) { - want := []GapCategory{Dependency, SafeAutocreate, ConfigChange, StatusLine, OracleRouting, UserState, PluginOwned} + want := []GapCategory{Dependency, SafeAutocreate, ConfigChange, StatusLine, OracleRouting, DrainRule, UserState, PluginOwned} if !reflect.DeepEqual(categoryPromptOrder, want) { t.Fatalf("categoryPromptOrder = %v, want %v", categoryPromptOrder, want) } diff --git a/internal/core/ahoy/prompt_order_test.go b/internal/core/ahoy/prompt_order_test.go index 6124dca5e..619dad9a1 100644 --- a/internal/core/ahoy/prompt_order_test.go +++ b/internal/core/ahoy/prompt_order_test.go @@ -32,6 +32,7 @@ func allCategoryGaps() []Gap { {ID: "skeleton.e", Category: SafeAutocreate, Resolvable: true}, {ID: "statusline.f", Category: StatusLine, Resolvable: true}, {ID: "oracle_routing.g", Category: OracleRouting, Resolvable: true}, + {ID: "drain_rule.h", Category: DrainRule, Resolvable: true}, } } @@ -50,6 +51,7 @@ func TestResolveApprovalPromptsInCanonicalOrder(t *testing.T) { "Apply config-change changes?", "Apply status-line changes?", "Apply oracle-routing changes?", + "Apply drain-rule changes?", "Apply user-state changes?", "Apply plugin-owned changes?", } @@ -66,7 +68,7 @@ func TestResolveApprovalPromptsInCanonicalOrder(t *testing.T) { // a category missing from it would be asked in the sorted-tail fallback, which // is still deterministic but no longer the order the apply pass acts in. func TestCategoryPromptOrderCoversEveryCategory(t *testing.T) { - all := []GapCategory{SafeAutocreate, ConfigChange, PluginOwned, Dependency, UserState, StatusLine, OracleRouting} + all := []GapCategory{SafeAutocreate, ConfigChange, PluginOwned, Dependency, UserState, StatusLine, OracleRouting, DrainRule} for _, c := range all { found := false for _, oc := range categoryPromptOrder { @@ -95,10 +97,10 @@ func TestResolveApprovalAsksUnknownCategoriesLast(t *testing.T) { for i := 0; i < 32; i++ { p := &recordingPrompter{confirm: true} resolveApproval(gaps, InstallOptions{}, p) - if len(p.asked) != 9 { - t.Fatalf("asked %d questions, want 9: %v", len(p.asked), p.asked) + if len(p.asked) != 10 { + t.Fatalf("asked %d questions, want 10: %v", len(p.asked), p.asked) } - tail := strings.Join(p.asked[7:], "|") + tail := strings.Join(p.asked[8:], "|") if tail != "Apply alpha changes?|Apply zeta changes?" { t.Fatalf("run %d: unknown categories not asked last and sorted: %v", i, p.asked) } diff --git a/internal/core/decide/decide.go b/internal/core/decide/decide.go index 793d9b9b4..1710aa4b7 100644 --- a/internal/core/decide/decide.go +++ b/internal/core/decide/decide.go @@ -85,6 +85,31 @@ type Decision struct { // hand-numbered ordinal had exactly the cross-branch collision no convention can // close. func Create(repoRoot, title string) (Decision, error) { + return create(repoRoot, title, renderSkeleton) +} + +// Stated is a decision whose words are already written: a setup offer that +// states a rule the person accepts at a prompt (the drain eligibility record, +// ruling BX2) mints it through the same seam as Create, so its id, date and +// filename come from the one allocator. The person's yes to the offer is the +// decision, so the record is written accepted. +type Stated struct { + // Title becomes the H1 and the slug, redacted as Create's is. + Title string + // Frontmatter is extra frontmatter lines, each "key: value\n", written + // after the store's nine keys. + Frontmatter string + // Body is everything below the H1: the four sections the store carries. + Body string +} + +// CreateStated mints a decision record for s and writes it accepted. On any +// refusal nothing is written. +func CreateStated(repoRoot string, s Stated) (Decision, error) { + return create(repoRoot, s.Title, func(d Decision) string { return renderStated(d, s) }) +} + +func create(repoRoot, title string, render func(Decision) string) (Decision, error) { trimmed := strings.Join(strings.Fields(title), " ") if trimmed == "" { return Decision{}, fmt.Errorf("decide: refusing to mint a decision with an empty title") @@ -125,7 +150,7 @@ func Create(repoRoot, title string) (Decision, error) { Date: dateFromStamp(stamp), Path: rel, } - body := renderSkeleton(created) + body := render(created) if err := fsutil.WriteFileAtomic(filepath.Join(repoRoot, filepath.FromSlash(rel)), []byte(body), 0o644); err != nil { return fmt.Errorf("decide: writing %s: %w", rel, err) } @@ -230,6 +255,15 @@ func renderSkeleton(d Decision) string { return b.String() } +// renderStated lays out a stated record: the store's frontmatter keys with +// `status: accepted`, the caller's extra keys, the H1, and the caller's body. +func renderStated(d Decision, s Stated) string { + head := renderSkeleton(d) + head = head[:strings.Index(head, "---\n\n")] + head = strings.Replace(head, "status: proposed\n", "status: accepted\n", 1) + return head + s.Frontmatter + "---\n\n# ADR-" + strings.TrimPrefix(d.ID, adrFamily+"-") + ": " + d.Title + "\n\n" + s.Body +} + // redactDecisionText sanitises the caller's title through the ONE canonical // detector — the same scanner the transcript store, the capture ledger, the // intent store and the launch bundler use. diff --git a/internal/core/decide/decide_test.go b/internal/core/decide/decide_test.go index 1a7eac27d..ca845755d 100644 --- a/internal/core/decide/decide_test.go +++ b/internal/core/decide/decide_test.go @@ -300,3 +300,27 @@ func routerBullet(content, lead string) (string, bool) { } return strings.Join(strings.Fields(strings.Join(lines[start:end], " ")), " "), true } + +// TestCreateStatedWritesAnAcceptedRecordWithItsFields: a stated decision is +// minted through the same seam as a skeleton, written accepted, with the extra +// frontmatter keys inside the block and the body below the H1. +func TestCreateStatedWritesAnAcceptedRecordWithItsFields(t *testing.T) { + root := t.TempDir() + d, err := CreateStated(root, Stated{Title: "A stated rule", Frontmatter: "drain_remedy: required\n", Body: "## Context\n\nwords\n"}) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(root, d.Path)) + if err != nil { + t.Fatal(err) + } + s := string(raw) + head, body, ok := strings.Cut(strings.TrimPrefix(s, "---\n"), "---\n") + if !ok || !strings.Contains(head, "\nstatus: accepted\n") || strings.Contains(head, "proposed") || + !strings.HasSuffix(head, "related_adrs: []\ndrain_remedy: required\n") || !strings.HasPrefix(head, "id: "+d.ID+"\n") { + t.Fatalf("the frontmatter is not the store's keys, accepted, then the stated ones:\n%s", s) + } + if !strings.HasPrefix(body, "\n# ADR-"+strings.TrimPrefix(d.ID, "adr-")+": A stated rule\n\n## Context\n\nwords\n") { + t.Fatalf("the body is not the H1 then the stated sections:\n%s", s) + } +} diff --git a/internal/surface/cli/ahoy_prompt_stdin_test.go b/internal/surface/cli/ahoy_prompt_stdin_test.go index 6f7cb2e76..bb29decea 100644 --- a/internal/surface/cli/ahoy_prompt_stdin_test.go +++ b/internal/surface/cli/ahoy_prompt_stdin_test.go @@ -253,11 +253,16 @@ func TestAhoyInstallYesDisclosesOptionalIdentityPin(t *testing.T) { t.Fatalf("install output not JSON: %v\n%s", err, jsonOut) } // The model-tier routing offers are optional too (itd-2609170822093401): a - // table is accepted only on an answer, so --yes names them as well. - want := "git_identity.unpinned oracle_routing.machine_offered oracle_routing.repo_offered" + // table is accepted only on an answer, so --yes names them as well; and so + // is the drain eligibility record (ruling BX2), which decides what an + // unattended agent may change. + want := "git_identity.unpinned oracle_routing.machine_offered oracle_routing.repo_offered drain_rule.offered" if strings.Join(res.OptionalSkipped, " ") != want { t.Fatalf("optional_skipped = %v, want [%s]\n%s", res.OptionalSkipped, want, jsonOut) } + if !strings.Contains(text, "the drain eligibility record decides what an unattended agent may change") { + t.Fatalf("the exclusion notice gives no reason for the drain rule offer:\n%s", text) + } if !strings.Contains(text, "a routing table decides which model every delegated step asks for") { t.Fatalf("the exclusion notice gives no reason for the routing offers:\n%s", text) } diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 567297b3a..4bb11a1ca 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3775,6 +3775,8 @@ func optionalSkipReason(id string) string { return "the status line rewrites a setting of the host harness and takes element choices, so it is only written against an answered prompt" case ahoy.OracleRoutingMachineGapID, ahoy.OracleRoutingRepoGapID: return "a routing table decides which model every delegated step asks for, so abcd's proposal is only accepted against an answered prompt" + case ahoy.DrainRuleOfferGapID: + return "the drain eligibility record decides what an unattended agent may change in this repository, so it is only added against an answered prompt" } return "" } From 1c1162548d029b5c1f3ba2aed31415cf24585f5d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:50:47 +0100 Subject: [PATCH 12/69] docs(decisions): record the drain's own-rule reading, its loud loosening and the waiting hand-backs One entry at the end of the log for rulings BX2 and H11 of 2026-09-29 as built by lane drainOwnRule: the record's four fields and where the drain reads them, every refusal without a fallback, how a loosened floor is named, the setup offer, and the choice of BOTH waiting hand-backs (a remedy opening "Waits on", and a deferral live at the current anchor tag) with why each is a person's and why the ruling is asked first. The spec of itd-82 stays open. Assisted-by: Claude:claude-opus-5-5 --- .abcd/work/DECISIONS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index a4366653b..b176f355c 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2587,3 +2587,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-29 — An autonomous run keeps at most five sub-agents alive at once, in any mix of roles (the user, directly to the run A orchestrator at 06:53Z, verbatim: "use up to five sub-agents from now on"; recorded by lane remerge-integ17 of autonomous run A). The ceiling of five supersedes the ceiling of four recorded above on 2026-09-24 (02:02Z). Reviews and audits run on Fable stay one at a time; that rule is unchanged. The run still counts a fork toward the ceiling, as its own lane rule, because a fork is an agent alive. - 2026-09-29 — Every new issue carries a remedy, and abcd's own automatic filers write one machine value when they have no fix (the product thinker's rulings BX3 and H12 of 2026-09-29, applied by lane remedyRequired of autonomous run A; partial of itd-82, whose decision 6 already required the field). BX3, verbatim: "REFUSE the filing; every new issue must carry remedy:". H12, verbatim: "'NO FIX YET' ALLOWED: automatic filers may write remedy 'none (filed automatically)'; the record is filed, drain skips it until a person writes a real remedy." As built: `capture` refuses a new issue with no remedy or a blank one, exit 2 and nothing written, naming `--remedy` and the machine value; the value is spelt once, `issueschema.MachineRemedy`, and written by every in-binary filer (the consistency pass, and an inbox report promoted without a remedy of its own, whose own remedy otherwise becomes the issue's); `abcd drain --dry-run` lists a record carrying it as ineligible, naming the automatic filer and the verb that answers it; and `capture remedy <iss-N> "<fix>"` writes or replaces the remedy on an open issue. A record filed before the rule carries none, stays readable and valid, and is listed as ineligible. The one-line capture friction that `commands/capture.md` and the principle `adversarial-review-scales-with-blast-radius` promised is tightened by the ruling, and both now say so. A person typing the machine value is REFUSED, by `capture --remedy` and by `capture remedy`, compared trimmed and case-folded: the value is useful only while it means that a machine filed the record, and a person has either a fix to name or the choice not to file; allowing it would let a hand-filed record pass as machine-filed and be skipped silently. A remedy chosen in an autonomous run cites its grounds, a prior-art or state-of-the-art check where the fix depends on outside practice (principle `prefer-sota`), in the capture's text; nothing checks that mechanically yet. - 2026-09-30 — Pending the person's ruling CL1, a promoted inbox report files the machine value: outside text never becomes a drain-eligible remedy without a person naming it (fix round of lane remedyRequired, autonomous run A, closing the review's trust finding on the entry of 2026-09-29 above). As built: `inbox promote` always writes `remedy: none (filed automatically)` (`issueschema.MachineRemedy`), whatever the report proposes, so `abcd drain --dry-run` lists the issue as ineligible; the sender's proposal stays in the issue's text under "Remedy the reporter proposes:", scrubbed like every other value the report carries, for a person to adopt with `capture remedy`. This narrows the entry above, which filed a report's own remedy as the issue's; the ruling CL1 may widen it again. +- 2026-09-30 — The drain reads the drained repository's own eligibility record, which may loosen abcd's floors loudly, and it hands back every record still waiting on a person (the product thinker's rulings BX2 and H11 of 2026-09-29, applied by lane drainOwnRule of autonomous run A; partial of itd-82, whose spec stays open for the host judgement, the lane, the hand-back writes and the pace). BX2, verbatim: "the PROJECT MUST HOLD the eligibility decision in its own record (e.g. added at setup); drain refuses there until it does." H11, verbatim: "MAY LOOSEN abcd's floors (a project may let drain take major/critical and security issues). NOTE for the lane: make a loosened floor loud (drain --dry-run and the drain start name every floor the project loosened), and keep abcd's own repository at the stricter default." As built: the record is the one accepted decision record in the repository's `.abcd/development/decisions/adrs/` whose frontmatter carries `drain_categories` (an inline list, a subset of the fixable set), `drain_severities` (an inline list of severities), `drain_security` (`handback` or `take`) and `drain_remedy` (`required`, its only value, since the remedy is the brief a lane works from); abcd's own adr-2609291342092738 carries the strict baseline, which the binary also bundles as the measure a loosening is named against, and a test fails if abcd's record loosens anything. A repository without such a record, with one that is only proposed or superseded, with two accepted, or with one that misses, misspells, repeats or mis-values a field, is refused by `drain --dry-run` and bare `drain` alike, exit 2 and nothing written, never falling back to the baseline or a looser rule; widening the categories past the fixable set is refused as a decision by kind, which H11 does not name. Every loosened floor (`severity major`, `severity critical`, `security`) is named in the dry run's text, on stderr in both output modes, in `--json` as `loosened`, and in the start's refusal. `ahoy install` offers the baseline as an accepted record, written through the decision store's mint only on an answered yes; `--yes` skips it and reports `drain_rule.offered` under `optional_skipped`, as the routing offers are. The gap the remedy lanes found (50 of 54 dry-run-eligible records waiting on a ruling) is closed by BOTH hand-backs, each its own rule: a remedy opening "Waits on" (compared case-folded) is handed back as `waits-on-ruling`, because taking it would make the ruling the remedy waits on; and a record whose `deferred_after` names the current anchor tag is handed back as `deferred`, because a person carried it past this release and the waiver is that person's decision for the cycle. Both hold whatever the repository's record says. `capture defer` writes a deferral only onto a `major` or `critical` record, which H11 now lets a record take, but this ledger also carries hand-written deferrals on minor records (62 of the 231 open records on this branch are handed back as `deferred`), and the rule holds them back the same way. They are asked after the category and severity hand-backs, whose fix a ruling or a lapse would not change, and the ruling before the deferral, because it names which decision is owed; a record carrying both waits on both. The release tags are read only when an open record carries a deferral, and a failure to read them refuses the plan rather than letting a live deferral through. The threat is stated in the drain brief chapter: the record is a repository-authored file deciding what an unattended agent may do, so a contributor's pull request can loosen it; what guards it is that the record is committed history reviewed like code, a loosening is loud on every run, abcd's own repository keeps the baseline under a test, and the store is read inside the checkout so a symlink leaving it is refused. From e73af97bece2d945fea2ee69f330992d54fbfc6c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:59:40 +0100 Subject: [PATCH 13/69] chore(issues): capture review-guardSet's four root bypasses and two siblings The security review of the default-word and trim lanes found four ways rm -rf still reaches the root past rm-rf-root-or-home: a default word written as an ANSI-C or locale string, an unquoted default word split on an assigned IFS, a quoted / in a replacement pattern on bash 5, and an indirect parameter with a default. The fix lane's sweep confirmed two more outside those four readings: $! read as a number though it is empty until a background job runs, and a run of / written before the home. Refs: iss-2609300057304812 Refs: iss-2609300057318410 Refs: iss-2609300057311045 Refs: iss-2609300057326778 Refs: iss-2609300057467536 Refs: iss-2609300057462186 Assisted-by: Claude:claude-opus-5-5 --- ...s-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md | 15 +++++++++++++++ ...-guard-allows-rm-rf-x-home-and-x-home-which.md | 15 +++++++++++++++ ...ows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md | 15 +++++++++++++++ ...ows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md | 15 +++++++++++++++ ...-rm-rf-home-and-rm-rf-home-which-delete-the.md | 15 +++++++++++++++ ...-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md | 15 +++++++++++++++ 6 files changed, 90 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609300057304812-the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md create mode 100644 .abcd/work/issues/open/iss-2609300057311045-on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md create mode 100644 .abcd/work/issues/open/iss-2609300057318410-the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md create mode 100644 .abcd/work/issues/open/iss-2609300057326778-the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md create mode 100644 .abcd/work/issues/open/iss-2609300057462186-the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md create mode 100644 .abcd/work/issues/open/iss-2609300057467536-the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md diff --git a/.abcd/work/issues/open/iss-2609300057304812-the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md b/.abcd/work/issues/open/iss-2609300057304812-the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md new file mode 100644 index 000000000..93bd55368 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300057304812-the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300057304812" +slug: "the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh" +severity: "major" +category: "security" +source: "impl-review" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "spellWord decodes an ANSI-C string with readAnsiCQuote, reads a locale string as the double-quoted string it holds, spells $! as its number or nothing, and reads any other $ that opens no expansion as the literal $ bash prints; grounds: bash 3.2, /bin/sh and bash 5.3 print / for each form under printf, and dash prints $/ (never the root)." +--- + +The guard allows rm -rf ${X:-$'/'}, ${X:-$"/"} and ${X:-$'\x2f'}, which bash 3.2, /bin/sh and bash 5.3 all print as / with X unset: spellWord reads a $ that opens no name as a word it cannot read and returns nil, so a default or alternative word written as an ANSI-C or a locale string is never read (review-guardSet MAJOR-1). The same reading misses ${X:-$!/}, which is / with no background job. diff --git a/.abcd/work/issues/open/iss-2609300057311045-on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md b/.abcd/work/issues/open/iss-2609300057311045-on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md new file mode 100644 index 000000000..3f83229f6 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300057311045-on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300057311045" +slug: "on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which" +severity: "major" +category: "security" +source: "impl-review" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "replacementTexts reads the pattern both ways (ending at a quoted / as bash 3.2 does, and past it as bash 5 does) and unions the texts; readPattern reads $\" outside double quotes as the double-quoted string it opens; grounds: printf under /bin/bash 3.2.57 and bash 5.3.9 on the reviewed forms." +--- + +On bash 5 the guard allows rm -rf ${X/"/"*/$HOME} and ${X//"/"*/$HOME}, which bash 5.3 prints as the home: readPattern ends a replacement pattern at a quoted /, as bash 3.2 does, while bash 5 keeps a quoted / in the pattern (review-guardSet MAJOR-3). The same reader takes $"" in a pattern for a literal $, so ${X%%$""*}/ reads as not whole and is allowed though every shell prints /. diff --git a/.abcd/work/issues/open/iss-2609300057318410-the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md b/.abcd/work/issues/open/iss-2609300057318410-the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md new file mode 100644 index 000000000..9561b5751 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300057318410-the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300057318410" +slug: "the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every" +severity: "major" +category: "security" +source: "impl-review" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "On a line that names IFS in any word at any payload layer, an unquoted expansion whose text the guard reads (a default or alternative word, a trim or replacement text, or HOME or PWD) reads as a capped spelling, which refuses rm alone; a variable of unknown value is not capped, so while IFS= read -r d; do rm -rf $d; done stays allowed. Capping is smaller than modelling which assignment reaches which expansion, as splitAfterIFS already rules; grounds: printf under bash 3.2, /bin/sh, dash and bash 5.3." +--- + +The guard allows IFS=x; rm -rf ${U:-x/x} (and ${U:+x/x}), which every shell splits on the assigned IFS into "" and /, so rm deletes the root; spellWord splits an unquoted default or alternative word on whitespace only (review-guardSet MAJOR-2). IFS=Uv; rm -rf $HOME/x splits the home into / the same way. diff --git a/.abcd/work/issues/open/iss-2609300057326778-the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md b/.abcd/work/issues/open/iss-2609300057326778-the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md new file mode 100644 index 000000000..699f1db07 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300057326778-the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300057326778" +slug: "the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh" +severity: "major" +category: "security" +source: "impl-review" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "spellParameterAt reads the parameter name as bash does (paramNameEnd): a name, a positional digit run, or one special byte, after an optional indirection !, and applies the operators to each; an indirection value past an operator reads as capped, since the variable it names is not in the line; grounds: printf under bash 3.2, /bin/sh, dash and bash 5.3." +--- + +The guard allows rm -rf ${!X:-/} and ${!X-/}, which bash 3.2 and /bin/sh print as / with X unset: spellParameterAt returns the expansion as written whenever the body does not begin with a name byte (review-guardSet MAJOR-4). The same test drops every positional and special parameter, so ${1:-/}, ${@:-/}, ${!:-/} and ${#:+/} are allowed though every shell prints /. diff --git a/.abcd/work/issues/open/iss-2609300057462186-the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md b/.abcd/work/issues/open/iss-2609300057462186-the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md new file mode 100644 index 000000000..2c00aa501 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300057462186-the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300057462186" +slug: "the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the" +severity: "major" +category: "security" +source: "impl-review" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/match.go" +remedy: "argValueMatches also compares a field whose leading run of / stands before $HOME, ${HOME}, $PWD or ${PWD} with that run taken out, since each of those values is an absolute path and a leading / before an absolute path names the same directory; grounds: POSIX pathname resolution reads a run of slashes as one." +--- + +The guard allows rm -rf /$HOME and rm -rf /${HOME}, which delete the home: the arg_values compare cleans a run of separators inside a path (//*, $HOME//) but not one written before a home spelling, so /$HOME names none of the values. Found in the fix-guardSet sibling sweep; outside the four review findings. diff --git a/.abcd/work/issues/open/iss-2609300057467536-the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md b/.abcd/work/issues/open/iss-2609300057467536-the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md new file mode 100644 index 000000000..1e30b9f87 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300057467536-the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300057467536" +slug: "the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash" +severity: "major" +category: "security" +source: "impl-review" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/tokenize.go" +remedy: "Spell $! in segment.spelled as its number and as nothing (the texts $! and empty), for the arg_values compare alone, leaving the token as it is so kill $! and every other reading are unchanged; grounds: printf of $!/ under bash 3.2, /bin/sh, dash and bash 5.3 prints / in a fresh shell." +--- + +The guard allows rm -rf $!/ and rm -rf "$!"/, which bash 3.2, /bin/sh, dash and bash 5.3 print as / when no background job has run: the tokenizer reads $! as a number that stays the text it is (simpleParamEnd), so the word is the literal $!/ and names nothing, though $! is empty until a job runs in the background. Found in the fix-guardSet sibling sweep; outside unknown.go. From 598f47756f2fefb31d83539867b42749a14205b8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:59:54 +0100 Subject: [PATCH 14/69] fix(guard): read quoted default words, a named IFS, bash 5 patterns and every parameter Four readings of a ${...} expansion let rm -rf reach the root or the home past rm-rf-root-or-home (review-guardSet). Each is now read as bash reads it, and a reading the guard cannot finish refuses: - spellWord decodes an ANSI-C string (readAnsiCQuote) and reads a locale string as the double-quoted string it holds, so ${X:-$'/'}, ${X:-$'\x2f'} and ${X:-$"/"} are /. $! is its number or nothing (${X:-$!/} is /), and any other $ that opens nothing is the $ it is instead of an unread word. - A line that names IFS in any word at any payload layer reads an unquoted default's or alternative's word, a trim's or replacement's texts, and an unquoted $HOME or $PWD as capped (capIFSSplits, ifsSplits): the fields rest on an IFS the guard does not model. Capping is the smaller correct change beside reading the assignment, which would need every way IFS can be set (export, read, declare, eval layers) and which splitAfterIFS already declines to model. A variable of unknown value is not capped, so while IFS= read -r d; do rm -rf $d; done stays allowed. The prefix form IFS=x rm -rf ${U:-x/x}, which bash does not apply to its own words, now refuses too. - replacementTexts reads the pattern where bash 3.2 ends it and where bash 5 does, past a quoted /, and unions the texts (${X/"/"*/$HOME}). readPattern reads $" as the double-quoted string it opens, so ${X%%$""*}/ is /. - spellParameterAt reads the parameter as bash does (paramNameEnd): after an indirection's !, a positional digit run, or one special byte, so ${!X:-/}, ${1:-/}, ${@:-/}, ${!:-/} and ${#:+/} read their words. An indirection's value past an operator is capped; alone or as a name list it keeps its text. Also found in the sweep and fixed here: a run of / before $HOME or $PWD names that directory (absoluteName), so rm -rf /$HOME blocks. 17-guard.md's mechanism paragraph says "any substring" and names the new readings. The corpus gives the same verdict on all 1099 lines at the base and here. Refs: iss-2609300057304812 Refs: iss-2609300057318410 Refs: iss-2609300057311045 Refs: iss-2609300057326778 Refs: iss-2609300057462186 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/17-guard.md | 15 +- internal/core/guard/guardset_test.go | 169 +++++++++++++++++ internal/core/guard/match.go | 20 ++ internal/core/guard/payload.go | 45 +++++ internal/core/guard/tokenize.go | 32 +++- internal/core/guard/unknown.go | 175 ++++++++++++++++-- 6 files changed, 432 insertions(+), 24 deletions(-) create mode 100644 internal/core/guard/guardset_test.go diff --git a/.abcd/development/brief/04-surfaces/17-guard.md b/.abcd/development/brief/04-surfaces/17-guard.md index c1e5ed8e6..9eeb5dee7 100644 --- a/.abcd/development/brief/04-surfaces/17-guard.md +++ b/.abcd/development/brief/04-surfaces/17-guard.md @@ -327,7 +327,8 @@ sequence places after it (`{$HOME,x}`, `$HOME/{.*,}`, `$HO{ME,}`, `$HO{M..M}E`); an expansion whose operator can leave the value as it is reads as the variable itself — a default, an assignment or an error message (`${HOME:-x}`), a trim or a pattern replacement (`${HOME%/}`, `${HOME#x}`, -`${HOME/x/y}`), a substring, a case change, and a subscript read to its +`${HOME/x/y}`), any substring, which also reads as the root and as nothing +(`${X:1}`, `${PWD:0:1}`), a case change, and a subscript read to its matching `]` with any text after it (`${HOME[x[0]]}`, `${HOME[0]]}`, which the bash 3.2 of macOS prints as the value); and an alternative, which prints its word or nothing, reads as that word as written (`${X:+$HOME}`, `${X:+/}`, @@ -335,7 +336,17 @@ word or nothing, reads as that word as written (`${X:+$HOME}`, `${X:+/}`, operator after a subscript (`${X[0]]:+$HOME}`). Unquoted, the alternative's word is split on whitespace and a substitution in it that prints nothing drops out, as bash splits and drops them (`${X:+$HOME }`, -`${X:+$(true)$HOME}`). A trim that leaves the path above the home +`${X:+$(true)$HOME}`). A word written as an ANSI-C or a locale string reads +as the text it decodes to (`${X:-$'\x2f'}`, `${X:-$"/"}`); a positional, a +special or an indirect parameter takes the same operators (`${1:-/}`, +`${#:+/}`, `${!X:-/}`, the last read as every target past its operator, +since the variable it names is not in the line); a replacement's pattern is +read both where bash 3.2 ends it and where bash 5 does, at a quoted `/` +(`${X/"/"*/$HOME}`); and on a line that names IFS, an unquoted default's or +alternative's word, and an unquoted home, reads as every target, since the +fields bash splits it into rest on that IFS (`IFS=x; rm -rf ${U:-x/x}`, +`IFS=Uv; rm -rf $HOME/x`). A run of `/` written before the home names the +home (`/$HOME`). A trim that leaves the path above the home (`${HOME%/*}`) blocks as the home does. Each target is also compared as a path with its redundant separators taken out, since the kernel reads a run of slashes as one, a `.` segment as the directory itself and the root as its own diff --git a/internal/core/guard/guardset_test.go b/internal/core/guard/guardset_test.go new file mode 100644 index 000000000..c15f8436d --- /dev/null +++ b/internal/core/guard/guardset_test.go @@ -0,0 +1,169 @@ +package guard + +import "testing" + +// TestQuotedDefaultWordsTheWrittenCompareReads — review-guardSet MAJOR-1. A +// default's or an alternative's word written as an ANSI-C string (`$'/'`, +// `$'\x2f'`, `$'\57'`, `$'/'`) or a locale string (`$"/"`) prints what +// it decodes to: bash 3.2, /bin/sh and bash 5.3 print `/` for +// `${X:-$'/'}` and `${X:-$"/"}` with X unset, as they do for the bare +// `rm -rf $'/'`. A `$` that opens nothing is the `$` it is (`${X:-$/}` +// prints `$/`), and `$!` can print nothing, which leaves the text beside it +// (`${X:-$!/}` is `/` with no background job). +func TestQuotedDefaultWordsTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf ${X:-$'/'}`, shellBare, VerdictBlock, home}, + {`rm -rf "${X:-$'/'}"`, shellBare, VerdictBlock, home}, + {`rm -rf ${X-$'/'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:=$'/'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:+$'/'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:-$'\x2f'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:-$'\57'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:-$'/'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:-$'\x2f\x00zz'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:-"$HOME"$'/'}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X:-$"/"}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X:+$"/"}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X:-$"$HOME"}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X:-$!/}`, all, VerdictBlock, home}, + // The look-alikes: a word that decodes to a path of its own, a + // quoted `$'` that stays text, and a `$` that opens nothing. + {`rm -rf ${X:-$'./build'}`, shellBare, VerdictAllow, ""}, + {`rm -rf ${X:-"$'/'"}`, shellBare, VerdictAllow, ""}, + {`rm -rf ${X:-$}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X:-$/}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X:-$$}`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestDefaultWordsSplitOnANamedIFSTheWrittenCompareReads — review-guardSet +// MAJOR-2. An unquoted default's or alternative's word is split into fields +// on the IFS the shell holds when it expands it, and an assignment in a +// command of its own changes that IFS: `IFS=x; rm -rf ${U:-x/x}` hands rm +// `""` and `/` on bash 3.2, /bin/sh, dash and bash 5.3. So is an unquoted +// `$HOME` (`IFS=Uv; rm -rf $HOME/x` hands rm `/` with HOME=/Users/dev). On a +// line that names IFS such a word refuses: the guard reads the split on the +// default IFS only. A quoted word is not split, and a variable of unknown +// value splits into text no more known than its value, so `while IFS= read +// -r d; do rm -rf $d; done` stays allowed. +func TestDefaultWordsSplitOnANamedIFSTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + checkSpellingCases(t, []spellingCase{ + {`IFS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`IFS=x; rm -rf ${U:+x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`IFS=x; rm -rf ${U-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`export IFS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`read IFS; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`IFS=x; rm -rf ${U:-${V:-x/x}}`, shellBare | shellSQ, VerdictBlock, home}, + {`IFS=x; eval rm -rf ${U:-x/x}`, shellBare, VerdictBlock, home}, + {`IFS=x; eval 'rm -rf ${U:-x/x}'`, shellBare, VerdictBlock, home}, + {`IFS=Uv; rm -rf $HOME/x`, shellBare | shellSQ, VerdictBlock, home}, + {`IFS=Uv; rm -rf ${HOME%/}/x`, shellBare | shellSQ, VerdictBlock, home}, + // The look-alikes: a quoted word, a variable of unknown value, and a + // line that names no IFS. + {`IFS=x; rm -rf "${U:-x/x}"`, shellBare, VerdictAllow, ""}, + {`while IFS= read -r d; do rm -rf "$d"; done`, shellBare | shellSQ, VerdictAllow, ""}, + {`while IFS= read -r d; do rm -rf $d; done`, shellBare | shellSQ, VerdictAllow, ""}, + {`while IFS= read -r d; do rm -rf ${d%/}; done`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${TMPDIR:-/tmp}/abcd-x`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestQuotedSlashReplacementsTheWrittenCompareReads — review-guardSet +// MAJOR-3. bash 5 reads a quoted `/` as part of a replacement's pattern, +// where bash 3.2 ends the pattern there: `${X/"/"*/$HOME}` prints the home on +// bash 5.3 and the value on bash 3.2. Both readings are the expansion's. +// A `$""` or `$"…"` in a pattern is the quoted text it holds +// (`${X%%$""*}/` is `/`). +func TestQuotedSlashReplacementsTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + checkSpellingCases(t, []spellingCase{ + {`rm -rf ${X/"/"*/$HOME}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X//"/"*/$HOME}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf "${X/"/"*/$HOME}"`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X/'/'*/$HOME}`, shellBare, VerdictBlock, home}, + {`rm -rf ${X/$"/"*/$HOME}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X/$""*/$HOME}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X%%$""*}/`, shellBare | shellSQ, VerdictBlock, home}, + // The look-alikes: a quoted `/` that replaces one byte, and a bracket. + {`rm -rf ./${X/"/"/_}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X/[/]*/$HOME}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "./${X//"/"/_}"`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestIndirectAndSpecialDefaultsTheWrittenCompareReads — review-guardSet +// MAJOR-4. bash 3.2 and /bin/sh read `${!X:-/}` as X's indirection with a +// default, and print `/` with X unset. The value an indirection names is not +// in the line, so its spelling past an operator reads as every value. A +// positional or special parameter takes the same operators (`${1:-/}`, +// `${@:-/}`, `${!:-/}` and `${#:+/}` print `/` on bash 3.2, /bin/sh, dash and +// bash 5.3), and its value is the parameter as written. +func TestIndirectAndSpecialDefaultsTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf ${!X:-/}`, all, VerdictBlock, home}, + {`rm -rf ${!X-/}`, all, VerdictBlock, home}, + {`rm -rf ${!X:-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${!X:+/}`, all, VerdictBlock, home}, + {`rm -rf ${1:-/}`, all, VerdictBlock, home}, + {`rm -rf ${1-$HOME}`, all, VerdictBlock, home}, + {`rm -rf ${10:-/}`, all, VerdictBlock, home}, + {`rm -rf ${@:-/}`, all, VerdictBlock, home}, + {`rm -rf ${*:-/}`, all, VerdictBlock, home}, + {`rm -rf ${!:-/}`, all, VerdictBlock, home}, + {`rm -rf ${#:+/}`, all, VerdictBlock, home}, + {`rm -rf ${?:+/}`, all, VerdictBlock, home}, + // The look-alikes: an indirection alone, a name list, a length, and + // a positional default naming a path of its own. + {`rm -rf ${!X}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${!X*}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${#X}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${1:-dist}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${1:-./build}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${1%/}`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + +// TestEverydayExpansionsStayAllowed pins the everyday idioms review-guardSet +// held allowed at the base and the head. +func TestEverydayExpansionsStayAllowed(t *testing.T) { + cases := []string{ + `rm -rf "${TMPDIR:-/tmp}/abcd-x"`, + `rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/abcd"`, + `rm -rf ${DIR:-./build}`, + `rm -rf ${f%.*}`, + `rm -rf ${p##*/}`, + `rm -rf "${DIR/#\~/$HOME}"`, + `rm -rf ${name//[^a-z]/}`, + `rm -rf ${1:-dist}`, + `rm -rf "./${X//\//_}"`, + `rm -rf ${X/foo/$HOME}`, + `rm -rf "${DIR%/}/build"`, + `rm -rf "$HOME/${d##*/}"`, + } + var sc []spellingCase + for _, c := range cases { + sc = append(sc, spellingCase{c, shellBare | shellSQ, VerdictAllow, ""}) + } + checkSpellingCases(t, sc) +} + +// TestSeparatorsBeforeTheHomeTheWrittenCompareReads — iss-2609300057462186. +// The home and the working directory are absolute paths, and a run of `/` +// written before one names the same directory: `rm -rf /$HOME` deletes the +// home. A separator after the name is a path beneath it. +func TestSeparatorsBeforeTheHomeTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + checkSpellingCases(t, []spellingCase{ + {`rm -rf /$HOME`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf //${HOME}/`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf "/$HOME"/*`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf /$HOME/build`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf /$HOMEDIR`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} diff --git a/internal/core/guard/match.go b/internal/core/guard/match.go index 205fdc3af..0bbb36c6a 100644 --- a/internal/core/guard/match.go +++ b/internal/core/guard/match.go @@ -722,6 +722,26 @@ func argValueMatches(values []string, written string) bool { return true } } + if lead := strings.TrimLeft(field, "/"); lead != field && absoluteName(lead) && argValueMatches(values, lead) { + return true + } + } + return false +} + +// absoluteName reports whether p begins with the home or the working +// directory written as a variable (`$HOME`, `${HOME}`, `$PWD`, `${PWD}`), +// whose value is an absolute path: a run of `/` written before it names the +// same directory, so argValueMatches also reads the field without that run +// (`rm -rf /$HOME` deletes the home; iss-2609300057462186). +func absoluteName(p string) bool { + for _, name := range []string{"HOME", "PWD"} { + if strings.HasPrefix(p, "${"+name+"}") { + return true + } + if strings.HasPrefix(p, "$"+name) && (len(p) == len(name)+1 || !isNameByte(p[len(name)+1])) { + return true + } } return false } diff --git a/internal/core/guard/payload.go b/internal/core/guard/payload.go index 40c7d7432..4e55d392b 100644 --- a/internal/core/guard/payload.go +++ b/internal/core/guard/payload.go @@ -97,6 +97,14 @@ func expandPayloads(segs []segment) ([]segment, []payloadSignal) { segs []segment depth int } + // A line that names IFS reads every word whose fields rest on the + // default IFS as past its bound (capIFSSplits), before any string is + // paired with the words it was written from, so the string's words are + // read past it too (`IFS=x; eval rm -rf ${U:-x/x}`). + ifsNamed := namesIFS(segs) + if ifsNamed { + capIFSSplits(segs) + } queue := []work{{segs: segs, depth: 0}} for len(queue) > 0 { item := queue[0] @@ -194,6 +202,13 @@ func expandPayloads(segs []segment) ([]segment, []payloadSignal) { s.home.addPayload(s.at, psegs) } out = append(out, psegs...) + switch { + case ifsNamed: + capIFSSplits(psegs) + case namesIFS(psegs): + ifsNamed = true + capIFSSplits(out) + } queue = append(queue, work{segs: psegs, depth: item.depth + 1}) } } @@ -472,6 +487,36 @@ func wordFeeds(s segment, keep func(int) bool) []feed { return append(rest, run) } +// namesIFS reports whether any word of segs names IFS, as splitAfterIFS +// reads a naming: an assignment in a command of its own, an `export`, a +// `read`, a prefix assignment, or any other word that holds the name. +func namesIFS(segs []segment) bool { + for _, s := range segs { + for _, tok := range s.tokens { + tally(len(tok)) + if strings.Contains(tok, "IFS") { + return true + } + } + } + return false +} + +// capIFSSplits reads each word of segs whose fields rest on the default IFS +// (segment.ifsSplit) as a spelling past its bound (spellCapped), which the +// arg_values compare reads as every value (review-guardSet MAJOR-2). It runs +// on a line that names IFS anywhere: which assignment reaches which +// expansion is not modelled, as splitAfterIFS does not model it, so a +// prefix assignment (`IFS=x rm -rf ${U:-x/x}`), which bash does not apply +// to its own command's words, refuses too. +func capIFSSplits(segs []segment) { + for _, s := range segs { + for i := range s.ifsSplit { + s.spelled[i] = []string{spellCapped} + } + } +} + // splitAfterIFS reports whether a segment carrying an unquoted fixed output // shares the command line, at any payload layer, with another segment that // names IFS (review7-guard finding 2). fixedOutputSegment splits an output on diff --git a/internal/core/guard/tokenize.go b/internal/core/guard/tokenize.go index 35735d2e8..88bfcda7b 100644 --- a/internal/core/guard/tokenize.go +++ b/internal/core/guard/tokenize.go @@ -92,6 +92,10 @@ type segment struct { // the token, where the variable is the unknown word's mark // (iss-2609290321312087). nil when no word holds a variable. spelled map[int][]string + // ifsSplit records, per token index, a spelled word whose fields rest on + // the default IFS (ifsSplits in unknown.go), which a line that names IFS + // reads as past its bound (capIFSSplits). nil when no word is. + ifsSplit map[int]bool // arrivals caches commandArrivals(tokens) once Check has its final // segments (walked records that it is set), so the walk to command position // is paid once per segment rather than once per entry. A segment built @@ -389,6 +393,8 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // and the expansion's text, "" where it is not known. spells map[int][]string curVarAt []varSite + // splits rides with the segment (segment.ifsSplit). + splits map[int]bool // curMask is parallel to cur and records, per byte, whether it reached // the tokenizer unquoted (wordStruct) and whether it began its word // (wordRawStart) — what the brace expander needs to read a word the way @@ -622,6 +628,17 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { addCur([]byte{varMark}, 0) curVarAt[len(curVarAt)-1].texts = texts } + // markIFSSplit files the word being built under segment.ifsSplit when its + // sites rest on the default IFS. + markIFSSplit := func(sites []varSite) { + if !ifsSplits(sites) { + return + } + if splits == nil { + splits = map[int]bool{} + } + splits[len(toks)] = true + } // recordSpelling files the word being built under segment.spelled when a // variable's mark is in it: word is the token it becomes, and whole // reports that the token is cur as built, so each mark's place is known. @@ -638,6 +655,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { switch { case whole: spells[len(toks)] = spellWritten(cur, curVarAt, nil) + markIFSSplit(curVarAt) case isUnknown(word): spells[len(toks)] = []string{unknownText} } @@ -666,6 +684,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { spells = map[int][]string{} } spells[len(toks)] = spellWritten(w.b, sites, w.m) + markIFSSplit(sites) } flushToken := func() { if !hasCur { @@ -750,12 +769,14 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { tokens: toks, chain: chain, braceGroup: braceGroup, globbed: globsOrNil(globs), stdinStream: curStdin || pipeNext || len(groupIn) > 0, literal: lits, feeds: feeds, piped: piped, stdinIn: groupIn, home: list, at: len(segs), variable: vars, spelled: spells, + ifsSplit: splits, }) toks = nil globs = nil lits = nil vars = nil spells = nil + splits = nil feeds = nil braceGroup = false pipeNext = false @@ -895,7 +916,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { openSubstitution := func(kind parenKind, pos int, procSub bool) { saved := &enclosing{ toks: toks, globs: globs, lits: lits, vars: vars, curVar: curVar, curSub: curSub, - spells: spells, curVarAt: curVarAt, + spells: spells, curVarAt: curVarAt, splits: splits, cur: cur, curMask: curMask, hasCur: hasCur, curGlob: curGlob, curBrace: curBrace, braceGroup: braceGroup, chain: chain, procSub: procSub, curStdin: curStdin, pipeNext: pipeNext, curDocs: curDocs, pieces: curPieces, @@ -904,7 +925,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { } toks, globs, lits, cur, curMask, hasCur, curGlob, curBrace, braceGroup = nil, nil, nil, nil, nil, false, false, false, false curPieces, vars, curVar, curSub = nil, nil, false, false - spells, curVarAt = nil, nil + spells, curVarAt, splits = nil, nil, nil // A substitution is a command string of its own: its pipelines begin // inside it. Its standard input is its command's: what was piped into // the groups around it, and the pipe into the command it sits in @@ -976,7 +997,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { curStdin, pipeNext, curDocs, curPieces = e.curStdin, e.pipeNext, e.curDocs, e.pieces feeds, curFeeds, pipeFrom, braceFrom, groupIn = e.feeds, e.curFeeds, e.pipeFrom, e.braceFrom, e.groupIn vars, curVar, curSub = e.vars, e.curVar, e.curSub - spells, curVarAt = e.spells, e.curVarAt + spells, curVarAt, splits = e.spells, e.curVarAt, e.splits resumeDocs(e) if !f.bare { addCur([]byte(arithmeticOperand), 0) @@ -1000,7 +1021,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { curStdin, pipeNext, curDocs, curPieces = e.curStdin, e.pipeNext, e.curDocs, e.pieces feeds, curFeeds, pipeFrom, braceFrom, groupIn = e.feeds, e.curFeeds, e.pipeFrom, e.braceFrom, e.groupIn vars, curVar, curSub = e.vars, e.curVar, e.curSub - spells, curVarAt = e.spells, e.curVarAt + spells, curVarAt, splits = e.spells, e.curVarAt, e.splits feedFrom(e.segStart) if e.procSub { addCur([]byte(procSubOperand), 0) @@ -1028,6 +1049,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { expandedBody(body) feedFrom(start) addVar(spellParameter(body, split)...) + curVarAt[len(curVarAt)-1].split = split if len(segs) > start { curSub = true } @@ -1478,6 +1500,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { end := simpleParamEnd(line, i+1) addVar(paramText(line[i:end])) curVarAt[len(curVarAt)-1].bare = true + curVarAt[len(curVarAt)-1].split = true lastList = false i = end case c == '$' && i+1 < len(line) && line[i+1] == '{': @@ -2173,6 +2196,7 @@ type enclosing struct { curSub bool spells map[int][]string curVarAt []varSite + splits map[int]bool cur []byte curMask []byte hasCur bool diff --git a/internal/core/guard/unknown.go b/internal/core/guard/unknown.go index 6ca1f9545..85b4f95b8 100644 --- a/internal/core/guard/unknown.go +++ b/internal/core/guard/unknown.go @@ -108,10 +108,46 @@ const varText = "\x01" // for a varMark read from a payload's text, whose name the string no longer // holds. bare records a name written unquoted and without braces, which the // unquoted text a brace group places after it runs on from (spellWritten). +// split records an expansion written unquoted, whose text bash splits into +// fields on IFS (ifsSplits). type varSite struct { at int texts []string bare bool + split bool +} + +// ifsSplits reports whether a word's sites include one whose fields rest on +// the default IFS (review-guardSet MAJOR-2): an unquoted expansion whose +// text the guard reads — a default's or an alternative's word, a trim's or +// a replacement's texts, or the home or the working directory it names. +// Under an IFS a line assigns, bash splits that text on other bytes, and +// the fields can be the root (`IFS=x; rm -rf ${U:-x/x}` hands rm `""` and +// `/`; `IFS=Uv; rm -rf $HOME/x` hands it `/`). An unquoted variable of +// unknown value (`$d`, `${d%/}`) splits into fields no more known than its +// value, and is not counted. +func ifsSplits(sites []varSite) bool { + for _, s := range sites { + if !s.split || len(s.texts) == 0 { + continue + } + if len(s.texts) > 1 { + return true + } + name := strings.TrimPrefix(s.texts[0], "$") + if strings.HasPrefix(name, "{") && strings.HasSuffix(name, "}") { + name = name[1 : len(name)-1] + } + if name == "" || name == "HOME" || name == "PWD" || name == s.texts[0] { + return true + } + for i := 0; i < len(name); i++ { + if !isNameByte(name[i]) { + return true + } + } + } + return false } // A written spelling (segment.spelled) is a SET of texts, one for each thing @@ -310,16 +346,24 @@ const quotedFieldText = "\x03" func spellParameterAt(body string, depth int, split bool) []string { raw := []string{"${" + body + "}"} - n := 0 - for n < len(body) && isNameByte(body[n]) { - n++ - } - if n == 0 || body[0] >= '0' && body[0] <= '9' { + indirect, n := paramNameEnd(body) + if n < 0 { return raw } name, rest := body[:n], body[n:] same := "${" + name + "}" value := []string{same} + if indirect { + // An indirection's value is the value of the variable its name + // holds, which the line does not spell: past an operator that can + // print it, it reads as every value (review-guardSet MAJOR-4). Alone, + // and as a name list (`${!X*}`, `${!A[@]}`), it keeps its text, as a + // variable of unknown value does. + if rest == "" || rest == "*" || rest == "@" { + return raw + } + value = []string{spellCapped} + } // orWord is the value, or the texts the word w can print. orWord := func(w string) []string { texts := value @@ -351,6 +395,10 @@ func spellParameterAt(body string, depth int, split bool) []string { rest = rest[k+1:] op := strings.IndexAny(rest, subscriptOperators) if op < 0 { + if indirect && rest == "" { + // `${!A[@]}` lists the array's keys. + return raw + } return value } rest, subscript = rest[op:], true @@ -402,6 +450,44 @@ func spellParameterAt(body string, depth int, split bool) []string { return texts } +// paramNameEnd reads the parameter a `${…}` body begins with, as bash reads +// it, and returns where its name ends, or -1 where no parameter begins +// there. The name is a variable's (`HOME`), a positional parameter's digits +// (`1`, `10`), or one special parameter's byte (`@`, `*`, `#`, `?`, `-`, +// `$`, `!`), each of which takes the operators a variable does: with no +// arguments and no background job, `${1:-/}`, `${@:-/}` and `${!:-/}` print +// `/`, and `${#:+/}` prints it whatever the count (review-guardSet MAJOR-4). +// A `!` before a name, a digit, `@`, `*` or `#` is an indirection +// (`${!X:-/}`), reported by indirect, and the name follows it. A `#` before +// a name is a length (`${#X}`), whose text after the `#` is read as no +// operator, so it keeps its text as written. +func paramNameEnd(body string) (indirect bool, end int) { + start := 0 + if len(body) > 1 && body[0] == '!' && (isNameByte(body[1]) || strings.IndexByte("@*#", body[1]) >= 0) { + indirect, start = true, 1 + } + if start >= len(body) { + return false, -1 + } + switch c := body[start]; { + case c >= '0' && c <= '9': + n := start + 1 + for n < len(body) && body[n] >= '0' && body[n] <= '9' { + n++ + } + return indirect, n + case isNameByte(c): + n := start + 1 + for n < len(body) && isNameByte(body[n]) { + n++ + } + return indirect, n + case strings.IndexByte("@*#?-$!", c) >= 0: + return indirect, start + 1 + } + return false, -1 +} + // trimTexts is the written spelling of a trim, whose operator and pattern // are rest (`%p`, `%%p`, `#p`, `##p`), where value is the variable's own: // the value, which the trim leaves where its pattern does not match, and @@ -440,7 +526,7 @@ func trimTexts(value []string, rest string) []string { if longest { p = rest[2:] } - sh := readPattern(p, false) + sh := readPattern(p, false, false) anchored := sh.lastPast if suffix { anchored = sh.firstPast @@ -475,11 +561,32 @@ func replacementTexts(value []string, rest string, depth int, split bool) []stri if p != "" && strings.IndexByte("/#%", p[0]) >= 0 { anchor, p = p[0], p[1:] } - sh := readPattern(p, true) + // bash 3.2 ends the pattern at a quoted `/` and bash 5 does not, so a + // pattern that holds one is read both ways and prints what either + // reading prints (review-guardSet MAJOR-3). + texts := value + var last patternShape + for n, quoted := range []bool{false, true} { + sh := readPattern(p, true, quoted) + if n > 0 && sh == last { + break + } + last = sh + texts = replacementShapeTexts(texts, p, sh, anchor, depth, split) + if capped(texts) || len(texts) > maxSpellings { + return []string{spellCapped} + } + } + return texts +} + +// replacementShapeTexts adds to texts what a replacement whose pattern p +// reads as sh prints (replacementTexts). +func replacementShapeTexts(texts []string, p string, sh patternShape, anchor byte, depth int, split bool) []string { whole := sh.whole() tail := anchor != '#' && sh.wide && roving(sh.firstPast) && anyWidth(sh.last) if !whole && !tail { - return value + return texts } s := "" if sh.end < len(p) { @@ -489,7 +596,6 @@ func replacementTexts(value []string, rest string, depth int, split bool) []stri if len(words) == 0 { words = []string{""} } - texts := value for _, w := range words { if whole { texts = appendText(texts, w) @@ -498,9 +604,6 @@ func replacementTexts(value []string, rest string, depth int, split bool) []stri texts = appendText(texts, "/"+w) } } - if capped(texts) || len(texts) > maxSpellings { - return []string{spellCapped} - } return texts } @@ -559,8 +662,12 @@ func (sh patternShape) whole() bool { // over to its close without being read again, so the cost is p's length. // A replacement's pattern ends at its first unescaped `/`, which bash 3.2 // reads as the end even inside quotes and brackets (`${X/[/]/c}` replaces -// `[`). -func readPattern(p string, replacement bool) patternShape { +// `[`); with quoted set, a `/` inside quotes is the pattern's own, as bash 5 +// reads it (`${X/"/"*/$HOME}` prints the home on bash 5.3 and the value on +// bash 3.2), and only one in a bracket or unquoted ends it. A `$"` outside +// double quotes is the double-quoted string it opens (`${X%%$""*}` is +// `${X%%*}`). +func readPattern(p string, replacement, quoted bool) patternShape { tally(len(p)) sh := patternShape{end: len(p)} add := func(e patElem) { @@ -595,7 +702,7 @@ func readPattern(p string, replacement bool) patternShape { for i := 0; i < len(p); { c := p[i] switch { - case replacement && c == '/': + case replacement && c == '/' && !(quoted && dq): sh.end = i return sh case c == '\\': @@ -614,7 +721,7 @@ func readPattern(p string, replacement bool) patternShape { return unread() } for j := i + 1; j < i+1+k; j++ { - if replacement && p[j] == '/' { + if replacement && !quoted && p[j] == '/' { sh.end = j return sh } @@ -642,6 +749,8 @@ func readPattern(p string, replacement bool) patternShape { } add(elemAny) i = end + 1 + case c == '$' && !dq && i+1 < len(p) && p[i+1] == '"': + i++ case c == '$' && !dq && i+1 < len(p) && p[i+1] == '\'': k := i + 2 for k < len(p) && p[k] != '\'' { @@ -777,7 +886,10 @@ func subscriptEnd(s string) int { // escapes removed, and each expansion in it a site spelled as a word's own // are (spellWritten), `${…}` through spellParameterAt one level deeper. // `${X:+$HOME/}` is `$HOME/`, `${X:+/}` is `/` and `${X:+"${HOME%/}"}` is -// `${HOME}`. A command substitution in it is its unknown output, which +// `${HOME}`. An ANSI-C string in it is the bytes it decodes to and a locale +// string the double-quoted string it holds (`${X:-$'\x2f'}` and +// `${X:-$"/"}` are `/`), and a `$` that opens no expansion is text. A +// command substitution in it is its unknown output, which // spellWritten drops as knownText does (`${X:+$(true)$HOME}` is `$HOME`). // Where split is set, each unquoted whitespace run is fieldMark, where bash // splits the word (`${X:+$HOME }` is `$HOME`). A word holding a quote or an @@ -844,10 +956,37 @@ func spellWord(w string, depth int, split bool) []string { word = append(word, mark) } i++ + case c == '$' && !dq && i+1 < len(w) && w[i+1] == '\'': + // An ANSI-C string is the bytes it decodes to, as bash hands + // them on: `${X:-$'\x2f'}` prints `/` (review-guardSet MAJOR-1). + decoded, _, next, err := readAnsiCQuote(w, i+2) + if err != nil { + return nil + } + word = append(word, decoded...) + i = next + case c == '$' && !dq && i+1 < len(w) && w[i+1] == '"': + // A locale string is the double-quoted string it holds: + // `${X:-$"/"}` prints `/`. Inside double quotes the `$` is text. + i++ + case c == '$' && i+1 < len(w) && w[i+1] == '!': + // `$!` is the last background job's number, or nothing where + // none ran, which leaves the text beside it: `${X:-$!/}` is `/`. + sites = append(sites, varSite{at: len(word), texts: []string{"$!", ""}}) + word = append(word, varMark) + i += 2 + case c == '$' && i+1 < len(w) && strings.IndexByte("$?#", w[i+1]) >= 0: + // A number, never empty, which no path an entry names can be. + word = append(word, w[i:i+2]...) + i += 2 case c == '$': end := simpleParamEnd(w, i+1) if end < 0 { - return nil + // A `$` that opens no expansion is the `$` it is (`$/`), and + // so is one before a quote inside double quotes (`"$'/'"`). + word = append(word, c) + i++ + continue } sites = append(sites, varSite{at: len(word), texts: []string{w[i:end]}}) word = append(word, varMark) From 5b51dbc320acc95e2f0bf81b32e1f9463f4f6e7e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:00:16 +0100 Subject: [PATCH 15/69] chore: resolve review-guardSet's four root bypasses and the /$HOME sibling Each record enters resolved/ with impact fix and resolved_by the fix commit. iss-2609300057467536 ($!/ read as a number) stays open: the spelling it needs lives in the tokenizer, beside the kill readings of $!. Resolves: iss-2609300057304812 Resolves: iss-2609300057318410 Resolves: iss-2609300057311045 Resolves: iss-2609300057326778 Resolves: iss-2609300057462186 Refs: iss-2609300057467536 Assisted-by: Claude:claude-opus-5-5 --- ...rd-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md | 8 ++++++++ ...sh-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md | 8 ++++++++ ...uard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md | 8 ++++++++ ...uard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md | 8 ++++++++ ...d-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md | 8 ++++++++ 5 files changed, 40 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609300057304812-the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md (71%) rename .abcd/work/issues/{open => resolved}/iss-2609300057311045-on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md (69%) rename .abcd/work/issues/{open => resolved}/iss-2609300057318410-the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md (69%) rename .abcd/work/issues/{open => resolved}/iss-2609300057326778-the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md (71%) rename .abcd/work/issues/{open => resolved}/iss-2609300057462186-the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md (72%) diff --git a/.abcd/work/issues/open/iss-2609300057304812-the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md b/.abcd/work/issues/resolved/iss-2609300057304812-the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md similarity index 71% rename from .abcd/work/issues/open/iss-2609300057304812-the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md rename to .abcd/work/issues/resolved/iss-2609300057304812-the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md index 93bd55368..d672dd847 100644 --- a/.abcd/work/issues/open/iss-2609300057304812-the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md +++ b/.abcd/work/issues/resolved/iss-2609300057304812-the-guard-allows-rm-rf-x-x-and-x-x2f-which-bash-3-2-bin-sh.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/unknown.go" remedy: "spellWord decodes an ANSI-C string with readAnsiCQuote, reads a locale string as the double-quoted string it holds, spells $! as its number or nothing, and reads any other $ that opens no expansion as the literal $ bash prints; grounds: bash 3.2, /bin/sh and bash 5.3 print / for each form under printf, and dash prints $/ (never the root)." +resolution: "spellWord decodes ANSI-C and locale strings, spells $! as its number or nothing, and reads a $ that opens nothing as text; TestQuotedDefaultWordsTheWrittenCompareReads" +impact: fix +resolved_by: + commit: "598f47756" --- The guard allows rm -rf ${X:-$'/'}, ${X:-$"/"} and ${X:-$'\x2f'}, which bash 3.2, /bin/sh and bash 5.3 all print as / with X unset: spellWord reads a $ that opens no name as a word it cannot read and returns nil, so a default or alternative word written as an ANSI-C or a locale string is never read (review-guardSet MAJOR-1). The same reading misses ${X:-$!/}, which is / with no background job. + +## Grounds + +- pursued: rm -rf ${X:-$'/'}, ${X:-$"/"} and ${X:-$'\x2f'} block bare and as payloads; a default word spelled with another quoting or escape that decodes to / and still allows would show it wrong diff --git a/.abcd/work/issues/open/iss-2609300057311045-on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md b/.abcd/work/issues/resolved/iss-2609300057311045-on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md similarity index 69% rename from .abcd/work/issues/open/iss-2609300057311045-on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md rename to .abcd/work/issues/resolved/iss-2609300057311045-on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md index 3f83229f6..807526de8 100644 --- a/.abcd/work/issues/open/iss-2609300057311045-on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md +++ b/.abcd/work/issues/resolved/iss-2609300057311045-on-bash-5-the-guard-allows-rm-rf-x-home-and-x-home-which.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/unknown.go" remedy: "replacementTexts reads the pattern both ways (ending at a quoted / as bash 3.2 does, and past it as bash 5 does) and unions the texts; readPattern reads $\" outside double quotes as the double-quoted string it opens; grounds: printf under /bin/bash 3.2.57 and bash 5.3.9 on the reviewed forms." +resolution: "replacementTexts unions the bash 3.2 and bash 5 pattern boundaries, and readPattern reads $\" as a double-quoted string; TestQuotedSlashReplacementsTheWrittenCompareReads" +impact: fix +resolved_by: + commit: "598f47756" --- On bash 5 the guard allows rm -rf ${X/"/"*/$HOME} and ${X//"/"*/$HOME}, which bash 5.3 prints as the home: readPattern ends a replacement pattern at a quoted /, as bash 3.2 does, while bash 5 keeps a quoted / in the pattern (review-guardSet MAJOR-3). The same reader takes $"" in a pattern for a literal $, so ${X%%$""*}/ reads as not whole and is allowed though every shell prints /. + +## Grounds + +- pursued: ${X/"/"*/$HOME} and ${X%%$""*}/ block while ${X/[/]*/$HOME} and ./${X/"/"/_} stay allowed; a bash 5 pattern boundary other than a quoted slash that still allows would show it wrong diff --git a/.abcd/work/issues/open/iss-2609300057318410-the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md b/.abcd/work/issues/resolved/iss-2609300057318410-the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md similarity index 69% rename from .abcd/work/issues/open/iss-2609300057318410-the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md rename to .abcd/work/issues/resolved/iss-2609300057318410-the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md index 9561b5751..98a602157 100644 --- a/.abcd/work/issues/open/iss-2609300057318410-the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md +++ b/.abcd/work/issues/resolved/iss-2609300057318410-the-guard-allows-ifs-x-rm-rf-u-x-x-and-u-x-x-which-every.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/unknown.go" remedy: "On a line that names IFS in any word at any payload layer, an unquoted expansion whose text the guard reads (a default or alternative word, a trim or replacement text, or HOME or PWD) reads as a capped spelling, which refuses rm alone; a variable of unknown value is not capped, so while IFS= read -r d; do rm -rf $d; done stays allowed. Capping is smaller than modelling which assignment reaches which expansion, as splitAfterIFS already rules; grounds: printf under bash 3.2, /bin/sh, dash and bash 5.3." +resolution: "a line that names IFS caps every unquoted default, alternative, trim or replacement word and an unquoted HOME or PWD; TestDefaultWordsSplitOnANamedIFSTheWrittenCompareReads" +impact: fix +resolved_by: + commit: "598f47756" --- The guard allows IFS=x; rm -rf ${U:-x/x} (and ${U:+x/x}), which every shell splits on the assigned IFS into "" and /, so rm deletes the root; spellWord splits an unquoted default or alternative word on whitespace only (review-guardSet MAJOR-2). IFS=Uv; rm -rf $HOME/x splits the home into / the same way. + +## Grounds + +- pursued: IFS=x; rm -rf ${U:-x/x} and IFS=Uv; rm -rf $HOME/x block, eval layers included; an IFS set through a name the guard cannot read (declare $(echo I)FS=x) stays the documented residual, and a spelled IFS assignment that still allows would show it wrong diff --git a/.abcd/work/issues/open/iss-2609300057326778-the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md b/.abcd/work/issues/resolved/iss-2609300057326778-the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md similarity index 71% rename from .abcd/work/issues/open/iss-2609300057326778-the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md rename to .abcd/work/issues/resolved/iss-2609300057326778-the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md index 699f1db07..264d31511 100644 --- a/.abcd/work/issues/open/iss-2609300057326778-the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md +++ b/.abcd/work/issues/resolved/iss-2609300057326778-the-guard-allows-rm-rf-x-and-x-which-bash-3-2-and-bin-sh.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/unknown.go" remedy: "spellParameterAt reads the parameter name as bash does (paramNameEnd): a name, a positional digit run, or one special byte, after an optional indirection !, and applies the operators to each; an indirection value past an operator reads as capped, since the variable it names is not in the line; grounds: printf under bash 3.2, /bin/sh, dash and bash 5.3." +resolution: "spellParameterAt reads indirect, positional and special parameters through paramNameEnd; TestIndirectAndSpecialDefaultsTheWrittenCompareReads" +impact: fix +resolved_by: + commit: "598f47756" --- The guard allows rm -rf ${!X:-/} and ${!X-/}, which bash 3.2 and /bin/sh print as / with X unset: spellParameterAt returns the expansion as written whenever the body does not begin with a name byte (review-guardSet MAJOR-4). The same test drops every positional and special parameter, so ${1:-/}, ${@:-/}, ${!:-/} and ${#:+/} are allowed though every shell prints /. + +## Grounds + +- pursued: ${!X:-/}, ${1:-/}, ${@:-/}, ${!:-/} and ${#:+/} block while ${!X}, ${!X*}, ${#X} and ${1:-dist} stay allowed; a parameter spelling bash reads with operators that still allows would show it wrong diff --git a/.abcd/work/issues/open/iss-2609300057462186-the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md b/.abcd/work/issues/resolved/iss-2609300057462186-the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md similarity index 72% rename from .abcd/work/issues/open/iss-2609300057462186-the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md rename to .abcd/work/issues/resolved/iss-2609300057462186-the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md index 2c00aa501..8d2b05a83 100644 --- a/.abcd/work/issues/open/iss-2609300057462186-the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md +++ b/.abcd/work/issues/resolved/iss-2609300057462186-the-guard-allows-rm-rf-home-and-rm-rf-home-which-delete-the.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/match.go" remedy: "argValueMatches also compares a field whose leading run of / stands before $HOME, ${HOME}, $PWD or ${PWD} with that run taken out, since each of those values is an absolute path and a leading / before an absolute path names the same directory; grounds: POSIX pathname resolution reads a run of slashes as one." +resolution: "argValueMatches reads a run of / before $HOME or $PWD as that directory; TestSeparatorsBeforeTheHomeTheWrittenCompareReads" +impact: fix +resolved_by: + commit: "598f47756" --- The guard allows rm -rf /$HOME and rm -rf /${HOME}, which delete the home: the arg_values compare cleans a run of separators inside a path (//*, $HOME//) but not one written before a home spelling, so /$HOME names none of the values. Found in the fix-guardSet sibling sweep; outside the four review findings. + +## Grounds + +- pursued: rm -rf /$HOME and //${HOME}/ block while /$HOME/build and /$HOMEDIR stay allowed; another prefix that names the home and still allows would show it wrong From 5e3fec20c87668ea673f6d4f60b6cf3aad5127e9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:17:16 +0100 Subject: [PATCH 16/69] fix(guard): read a parameter that can print nothing as the text beside it `rm -rf $!/` was allowed: the tokenizer kept a top-level `$!` as the number it prints and spelled the word as the literal `$!/`, but before any job runs in the background `$!` prints nothing, and bash 3.2, /bin/sh, dash and bash 5.3 all hand rm `/`. The written spelling (segment.spelled, read only by the arg_values compare) now holds the empty text beside each parameter that can print nothing at the top of a fresh shell (emptyable): `$!`, `$@`, `$*`, a positional one, `$_` (after `x=` or `true ""`) and `$-` (dash), braced or not, quoted or not, at the top level, in a default's word and as the value every operator reads. `$!` keeps its text in the token (addBang: a site over the two bytes, varSite.width), so `kill $!`, `wait $!`, `echo $!` and `rm -f "$tmp.$!"` read exactly as before; a payload pairing leaves a word with no variable's mark alone. The empty reading splits into no field, so a line naming IFS counts these sites as it did (varSite.empty). Sibling found in the sweep and fixed here: a trim's or a replacement's pattern read `$!` as literal text, so `rm -rf ${PWD%%$!*}/` (`/` in every shell) was allowed; `$!` there is now text of any length, as `$Y` is. Corpus: the 1099 repo-mined and adversarial lines give the same verdict at 5b51dbc32 and here. Refs: iss-2609300057467536 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/17-guard.md | 7 +- internal/core/guard/guardset_test.go | 65 ++++++++++ internal/core/guard/payload.go | 5 + internal/core/guard/tokenize.go | 41 ++++++- internal/core/guard/unknown.go | 113 ++++++++++++++++-- internal/core/guard/unknownsites_test.go | 1 + 6 files changed, 216 insertions(+), 16 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/17-guard.md b/.abcd/development/brief/04-surfaces/17-guard.md index 9eeb5dee7..fcb764327 100644 --- a/.abcd/development/brief/04-surfaces/17-guard.md +++ b/.abcd/development/brief/04-surfaces/17-guard.md @@ -340,7 +340,12 @@ drops out, as bash splits and drops them (`${X:+$HOME }`, as the text it decodes to (`${X:-$'\x2f'}`, `${X:-$"/"}`); a positional, a special or an indirect parameter takes the same operators (`${1:-/}`, `${#:+/}`, `${!X:-/}`, the last read as every target past its operator, -since the variable it names is not in the line); a replacement's pattern is +since the variable it names is not in the line); a parameter that can print +nothing at the top of a fresh shell — `$!` before any job runs in the +background, `$@`, `$*` and a positional one with no argument, `$_` after +`x=`, and `$-` under dash — also reads as the text beside it (`$!/`, +`"${1}"/` and `/$!` are `/`), and `$!` in a pattern as text of any length +(`${PWD%%$!*}` is `${PWD%%*}`); a replacement's pattern is read both where bash 3.2 ends it and where bash 5 does, at a quoted `/` (`${X/"/"*/$HOME}`); and on a line that names IFS, an unquoted default's or alternative's word, and an unquoted home, reads as every target, since the diff --git a/internal/core/guard/guardset_test.go b/internal/core/guard/guardset_test.go index c15f8436d..2ab108d68 100644 --- a/internal/core/guard/guardset_test.go +++ b/internal/core/guard/guardset_test.go @@ -167,3 +167,68 @@ func TestSeparatorsBeforeTheHomeTheWrittenCompareReads(t *testing.T) { {`rm -rf /$HOMEDIR`, shellBare | shellSQ, VerdictAllow, ""}, }) } + +// TestParametersThatPrintNothingTheWrittenCompareReads — iss-2609300057467536. +// A parameter that can print nothing at the top of a fresh shell leaves the +// text beside it: bash 3.2, /bin/sh, dash and bash 5.3 print `/` for +// `$!/` (no background job ran), `$@/`, `$*/` and `$1/` (no argument), +// `$_/` after `x=` or `true ""`, and dash for `$-/` (no option letter), +// braced or not, quoted or not. A number that is never empty (`$$`, `$?`, +// `$#`) and the shell's name (`$0`) name no path, and the job's number +// stays the operand `kill` and `wait` take. +func TestParametersThatPrintNothingTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf $!/`, all, VerdictBlock, home}, + {`rm -rf "$!"/`, all, VerdictBlock, home}, + {`rm -rf "$!/"`, all, VerdictBlock, home}, + {`rm -rf /$!`, all, VerdictBlock, home}, + {`rm -rf ~$!`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf $!/*`, all, VerdictBlock, home}, + {`rm -rf $(true)$!/`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf {$!,x}/`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${!}/`, all, VerdictBlock, home}, + {`rm -rf "${!}"/`, all, VerdictBlock, home}, + {`rm -rf $-/`, all, VerdictBlock, home}, + {`rm -rf "${-}/"`, all, VerdictBlock, home}, + {`rm -rf $_/`, all, VerdictBlock, home}, + {`rm -rf "${_}"/`, all, VerdictBlock, home}, + {`rm -rf $@/`, all, VerdictBlock, home}, + {`rm -rf "$@"/`, all, VerdictBlock, home}, + {`rm -rf "${@}/"`, all, VerdictBlock, home}, + {`rm -rf $*/`, all, VerdictBlock, home}, + {`rm -rf "$*/"`, all, VerdictBlock, home}, + {`rm -rf ${*}/`, all, VerdictBlock, home}, + {`rm -rf $1/`, all, VerdictBlock, home}, + {`rm -rf "${1}"/`, all, VerdictBlock, home}, + {`rm -rf ${10}/`, all, VerdictBlock, home}, + {`rm -rf ${X:-$@/}`, all, VerdictBlock, home}, + {`rm -rf ${X:-$_/}`, all, VerdictBlock, home}, + {`rm -rf ${@%x}/`, all, VerdictBlock, home}, + // In a pattern too: `$!*` can be `*`, which takes all of PWD. + {`rm -rf ${PWD%%$!*}/`, all, VerdictBlock, home}, + {`rm -rf "${X%%"$!"*}"/`, shellBare | shellSQ, VerdictBlock, home}, + // The look-alikes: a parameter that is never empty, a name that runs + // on past the `_`, and the job's number where it names no path. + {`rm -rf $$/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf $?/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf $#/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf $0/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${0}/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf $_x/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf $!`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "$1"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -f "$tmp.$!"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "$d/$!"`, shellBare | shellSQ, VerdictAllow, ""}, + {`kill $!`, shellBare | shellSQ, VerdictAllow, ""}, + {`wait $!`, shellBare | shellSQ, VerdictAllow, ""}, + {`echo $!`, shellBare | shellSQ, VerdictAllow, ""}, + // The empty reading splits into no field: a line that names IFS + // reads these as it did before it (ifsSplits). + {`IFS=, ; rm -rf $1`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS=, ; rm -rf ${1}`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS=, ; rm -rf ${1%/}`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS=, ; rm -rf $_`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} diff --git a/internal/core/guard/payload.go b/internal/core/guard/payload.go index 4e55d392b..db99a3fca 100644 --- a/internal/core/guard/payload.go +++ b/internal/core/guard/payload.go @@ -362,6 +362,11 @@ func spellPayload(psegs []segment, named []string) { continue } for j := range m.spelled { + if !isUnknown(m.tokens[j]) { + // A word spelled with no variable's mark (`$!`, addBang) + // keeps the spelling its own reading gave it. + continue + } ws, ok := n.spelled[j] if !ok { if isUnknown(n.tokens[j]) { diff --git a/internal/core/guard/tokenize.go b/internal/core/guard/tokenize.go index 88bfcda7b..756d013ca 100644 --- a/internal/core/guard/tokenize.go +++ b/internal/core/guard/tokenize.go @@ -628,6 +628,23 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { addCur([]byte{varMark}, 0) curVarAt[len(curVarAt)-1].texts = texts } + // addParam leaves the mark of a parameter written without braces, whose + // text is text (`$HOME`, `$1`), with the texts it can print + // (paramTexts). + addParam := func(text string) { + texts := paramTexts(text) + addVar(texts...) + curVarAt[len(curVarAt)-1].empty = len(texts) > 1 + } + // addBang keeps a `$!` in the word as the text it is, which every + // reading takes as the job's number (`kill $!`), and records a site on + // it for the written spelling alone: the number, or nothing where no job + // ran in the background, which leaves the text beside it, so `$!/` is + // also `/` to arg_values (iss-2609300057467536). + addBang := func(mask byte) { + curVarAt = append(curVarAt, varSite{at: len(cur), texts: paramTexts("$!"), width: 2}) + addCur([]byte("$!"), mask) + } // markIFSSplit files the word being built under segment.ifsSplit when its // sites rest on the default IFS. markIFSSplit := func(sites []varSite) { @@ -646,7 +663,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // unknownFromOpenExpansion rewrote — is filed as unknownText, which no // value names. recordSpelling := func(word string, whole bool) { - if !curVar { + if len(curVarAt) == 0 { return } if spells == nil { @@ -665,7 +682,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // name the group's unquoted text runs on from is read as bash reads it // after the expansion (`$HO{ME,}` is `$HOME`). recordBraceSpelling := func(word string, w bword) { - if !curVar { + if len(curVarAt) == 0 { return } var sites []varSite @@ -1049,7 +1066,9 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { expandedBody(body) feedFrom(start) addVar(spellParameter(body, split)...) - curVarAt[len(curVarAt)-1].split = split + site := &curVarAt[len(curVarAt)-1] + site.split = split + site.empty = emptiedParameter(body, split, site.texts) if len(segs) > start { curSub = true } @@ -1172,8 +1191,13 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { j += 2 continue } + if line[j] == '$' && j+1 < len(line) && line[j+1] == '!' { + addBang(0) + j += 2 + continue + } if k := simpleParamEnd(line, j+1); line[j] == '$' && k >= 0 { - addVar(paramText(line[j:k])) + addParam(paramText(line[j:k])) j = k continue } @@ -1492,13 +1516,17 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { parens[len(parens)-1].end = end lastList = false i += 3 + case c == '$' && i+1 < len(line) && line[i+1] == '!': + addBang(wordStruct) + lastList = false + i += 2 case c == '$' && simpleParamEnd(line, i+1) >= 0: // A parameter expansion (iss-2609251824244354): the value it prints // is not in the command line, so the word holds unknownMark where // it goes (unknown.go), and `--$X` is a flag of unknown name as // `--$(x)` is. A `$` that is quoted or escaped never reaches here. end := simpleParamEnd(line, i+1) - addVar(paramText(line[i:end])) + addParam(paramText(line[i:end])) curVarAt[len(curVarAt)-1].bare = true curVarAt[len(curVarAt)-1].split = true lastList = false @@ -1976,7 +2004,8 @@ func closingDoubleQuote(line string, i int, budget *int) int { // 0), or `$@`, `$*` or `$-`, whose values are any text. It returns -1 where // the `$` opens no such expansion. `$$`, `$!`, `$?` and `$#` print a number, // which no flag, name or path an entry names can be, as an arithmetic -// expansion's does, and stay the text they are. A name runs on across a +// expansion's does, and stay the text they are; `$!` can also print nothing, +// which its written spelling alone reads (addBang). A name runs on across a // backslash-newline, which bash drops before it reads the name, so `$HO\⏎ME` // is `$HOME` (iss-2609290419119456); paramText is the name as bash reads it. func simpleParamEnd(line string, i int) int { diff --git a/internal/core/guard/unknown.go b/internal/core/guard/unknown.go index 85b4f95b8..0d10eb3fd 100644 --- a/internal/core/guard/unknown.go +++ b/internal/core/guard/unknown.go @@ -18,7 +18,8 @@ import ( // reader of a token asks this file what the word can be. An arithmetic // expansion is not unknown in that sense: its output is a number, which no // flag, subcommand or path the registry names can be, and neither are `$$`, -// `$!`, `$?` and `$#`. +// `$!`, `$?` and `$#`. `$!` is also nothing before any job runs in the +// background, which only the written spelling reads (emptyable). // // The rule is that an unknown word fails closed in every role it could play, // and a reader that can read a word more than one way reads it every way — the @@ -110,11 +111,51 @@ const varText = "\x01" // unquoted text a brace group places after it runs on from (spellWritten). // split records an expansion written unquoted, whose text bash splits into // fields on IFS (ifsSplits). +// +// empty records that the texts hold the empty text only because the +// parameter can print nothing (emptyable), which splits into no field and +// so is not counted where ifsSplits reads the site. +// +// width is the number of bytes of the word the site stands on where the +// word keeps the text as written rather than a mark (`$!`, which every +// other reading takes as the job's number it is, addBang), and 0 for a +// mark, which is one byte. type varSite struct { at int texts []string bare bool split bool + empty bool + width int +} + +// emptyable reports whether the parameter named name — the text after its +// `$`, or between its braces — can print nothing at the top of a fresh +// shell, where it leaves only the text beside it (iss-2609300057467536): `!` +// before any job runs in the background, `@`, `*` and a positional +// parameter's digits with no argument, `_` after `x=` or `true ""`, and `-`, +// which dash starts with no option letter in. bash 3.2, /bin/sh, dash and +// bash 5.3 print `/` for `$!/`, `$@/` and `$1/`. `$0` is the shell's name +// and `$$`, `$?` and `$#` are numbers, never empty. +func emptyable(name string) bool { + switch name { + case "!", "@", "*", "-", "_": + return true + } + if name == "" || strings.TrimLeft(name, "0123456789") != "" { + return false + } + return strings.TrimLeft(name, "0") != "" +} + +// paramTexts is the written spelling of a parameter written without braces +// (`$HOME`, `$1`, `$@`): the parameter itself, and the empty text where it +// can print nothing (emptyable). +func paramTexts(text string) []string { + if len(text) > 1 && emptyable(text[1:]) { + return []string{text, ""} + } + return []string{text} } // ifsSplits reports whether a word's sites include one whose fields rest on @@ -128,17 +169,26 @@ type varSite struct { // value, and is not counted. func ifsSplits(sites []varSite) bool { for _, s := range sites { - if !s.split || len(s.texts) == 0 { + texts := s.texts + if s.empty { + texts = nil + for _, t := range s.texts { + if t != "" { + texts = append(texts, t) + } + } + } + if !s.split || len(texts) == 0 { continue } - if len(s.texts) > 1 { + if len(texts) > 1 { return true } - name := strings.TrimPrefix(s.texts[0], "$") + name := strings.TrimPrefix(texts[0], "$") if strings.HasPrefix(name, "{") && strings.HasSuffix(name, "}") { name = name[1 : len(name)-1] } - if name == "" || name == "HOME" || name == "PWD" || name == s.texts[0] { + if name == "" || name == "HOME" || name == "PWD" || name == texts[0] { return true } for i := 0; i < len(name); i++ { @@ -203,6 +253,11 @@ func spellWritten(word []byte, sites []varSite, mask []byte) []string { } site := sites[k] k++ + if site.width > 1 { + // The site keeps its text in the word (`$!`): each text it can + // print stands in place of all of it. + p += site.width - 1 + } if len(site.texts) == 0 { add(unknownMark) continue @@ -322,7 +377,7 @@ func paramText(text string) string { return strings.ReplaceAll(text, "\\\n", "") // run in it is spelled fieldMark, which the compare splits on // (argValueMatches). func spellParameter(body string, split bool) []string { - return spellParameterAt(paramText(body), 0, split) + return spellParameterAt(paramText(body), 0, split, true) } // fieldMark stands in a spelling where bash splits a word into fields: at an @@ -344,7 +399,35 @@ const quotedFieldMark = '\x03' // quotedFieldText is quotedFieldMark as a string. const quotedFieldText = "\x03" -func spellParameterAt(body string, depth int, split bool) []string { +// emptiedParameter reports whether texts, the written spelling of the +// `${…}` whose text between the braces is body, hold the empty text only +// because its parameter can print nothing (varSite.empty): read without that +// reading, the expansion prints no empty text. +func emptiedParameter(body string, split bool, texts []string) bool { + body = paramText(body) + if indirect, n := paramNameEnd(body); !split || indirect || n < 0 || !emptyable(body[:n]) { + // Only a split site is counted, and only an emptyable parameter + // adds the empty text. + return false + } + has := false + for _, t := range texts { + has = has || t == "" + } + if !has { + return false + } + for _, t := range spellParameterAt(body, 0, split, false) { + if t == "" { + return false + } + } + return true +} + +// spellParameterAt is spellParameter at depth, with empty reporting whether +// a parameter that can print nothing (emptyable) is read as printing it. +func spellParameterAt(body string, depth int, split, empty bool) []string { raw := []string{"${" + body + "}"} indirect, n := paramNameEnd(body) if n < 0 { @@ -353,6 +436,11 @@ func spellParameterAt(body string, depth int, split bool) []string { name, rest := body[:n], body[n:] same := "${" + name + "}" value := []string{same} + if empty && !indirect && emptyable(name) { + // `${!}`, `${@}`, `${1}` can print nothing (emptyable), and every + // operator reads that nothing as it reads a value. + value = append(value, "") + } if indirect { // An indirection's value is the value of the variable its name // holds, which the line does not spell: past an operator that can @@ -770,6 +858,13 @@ func readPattern(p string, replacement, quoted bool) patternShape { i = end continue } + if i+1 < len(p) && p[i+1] == '!' { + // The job's number, or nothing where none ran in the + // background (emptyable): `${X%%$!*}` is `${X%%*}`. + add(elemAny) + i += 2 + continue + } literal(c) i++ case dq: @@ -930,7 +1025,7 @@ func spellWord(w string, depth int, split bool) []string { if end < 0 { return nil } - sites = append(sites, varSite{at: len(word), texts: spellParameterAt(w[i+2:end], depth+1, split && !dq)}) + sites = append(sites, varSite{at: len(word), texts: spellParameterAt(w[i+2:end], depth+1, split && !dq, true)}) word = append(word, varMark) i = end + 1 case c == '$' && i+1 < len(w) && w[i+1] == '(': @@ -988,7 +1083,7 @@ func spellWord(w string, depth int, split bool) []string { i++ continue } - sites = append(sites, varSite{at: len(word), texts: []string{w[i:end]}}) + sites = append(sites, varSite{at: len(word), texts: paramTexts(w[i:end])}) word = append(word, varMark) i = end default: diff --git a/internal/core/guard/unknownsites_test.go b/internal/core/guard/unknownsites_test.go index 79b7dc42f..795c226b8 100644 --- a/internal/core/guard/unknownsites_test.go +++ b/internal/core/guard/unknownsites_test.go @@ -94,6 +94,7 @@ var wordReaders = map[string]string{ "committedRegistry": "exempt: hands git its own options to read the committed registry; reads no command word", "allReserved": "exempt: reserved words are grammar, which no substitution prints", "keywordAt": "exempt: reserved words are grammar, which no substitution prints", + "emptyable": "exempt: reads a parameter's name after its `$` (`-` is the option-letter parameter), never a command word", "readHeredocDelim": "exempt: the `<<-` operator is grammar", "simpleParamEnd": "exempt: reads the `$-` special parameter's name, grammar that makes the word unknown", "spellParameterAt": "exempt: reads a `${…}` expansion's `-` operator (`${HOME:-x}`), grammar that spells the variable and the default's word for arg_values", From bb652a353ef29abcade0947aae327338ad302960 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:17:37 +0100 Subject: [PATCH 17/69] =?UTF-8?q?chore:=20resolve=20iss-2609300057467536?= =?UTF-8?q?=20=E2=80=94=20a=20parameter=20that=20can=20print=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard reads `rm -rf $!/`, its special and positional siblings and `$!` in a trim pattern as the root (fixed in 5e3fec20c). Impact fix. Resolves: iss-2609300057467536 Assisted-by: Claude:claude-opus-5-5 --- ...d-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609300057467536-the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md (61%) diff --git a/.abcd/work/issues/open/iss-2609300057467536-the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md b/.abcd/work/issues/resolved/iss-2609300057467536-the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md similarity index 61% rename from .abcd/work/issues/open/iss-2609300057467536-the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md rename to .abcd/work/issues/resolved/iss-2609300057467536-the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md index 1e30b9f87..b18493555 100644 --- a/.abcd/work/issues/open/iss-2609300057467536-the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md +++ b/.abcd/work/issues/resolved/iss-2609300057467536-the-guard-allows-rm-rf-and-rm-rf-which-bash-3-2-bin-sh-dash.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/tokenize.go" remedy: "Spell $! in segment.spelled as its number and as nothing (the texts $! and empty), for the arg_values compare alone, leaving the token as it is so kill $! and every other reading are unchanged; grounds: printf of $!/ under bash 3.2, /bin/sh, dash and bash 5.3 prints / in a fresh shell." +resolution: "rm -rf $!/ and its siblings ($@/, $*/, $1/, $_/, $-/, braced and quoted, and $! in a trim pattern) now read as the root: the written spelling holds the empty text beside a parameter that can print nothing, while kill $! and every other reading keep the token." +impact: fix +resolved_by: + commit: "5e3fec20c" --- The guard allows rm -rf $!/ and rm -rf "$!"/, which bash 3.2, /bin/sh, dash and bash 5.3 print as / when no background job has run: the tokenizer reads $! as a number that stays the text it is (simpleParamEnd), so the word is the literal $!/ and names nothing, though $! is empty until a job runs in the background. Found in the fix-guardSet sibling sweep; outside unknown.go. + +## Grounds + +- pursued: every word a parameter that can print nothing leaves as / (or ~, $HOME) blocks as rm-rf-root-or-home, and the 1099-line corpus keeps its verdicts; a shell that prints the empty reading as anything but the text beside it, or an everyday $!/$@ idiom that now blocks, would show it wrong. From a99b276b17f6a2a19f6fca2bc9002a3e0232bb73 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:53:53 +0100 Subject: [PATCH 18/69] chore(issues): capture reverify-guardSet's four finding classes The re-verification of the guard rounds found one bypass, two over-blocks and a pre-existing class: an IFS named through an expansion (a declaration, a read or printf -v target, an eval'd assignment, arithmetic), the empty value kept under a colon default, a positional slice read as a substring, and an expansion that prints nothing beside the root (an empty default word, a subscript, a case change or a transform). Refs: iss-2609300651115290 Refs: iss-2609300651127327 Refs: iss-2609300651122268 Refs: iss-2609300651133651 Assisted-by: Claude:claude-opus-5-5 --- ...home-is-bypassed-by-an-ifs-named-through-an.md | 15 +++++++++++++++ ...me-over-blocks-a-positional-slice-rm-rf-2-1.md | 15 +++++++++++++++ ...me-over-blocks-an-everyday-clean-line-rm-rf.md | 15 +++++++++++++++ ...r-home-allows-expansions-that-print-nothing.md | 15 +++++++++++++++ 4 files changed, 60 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609300651115290-rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an.md create mode 100644 .abcd/work/issues/open/iss-2609300651122268-rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md create mode 100644 .abcd/work/issues/open/iss-2609300651127327-rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf.md create mode 100644 .abcd/work/issues/open/iss-2609300651133651-rm-rf-root-or-home-allows-expansions-that-print-nothing.md diff --git a/.abcd/work/issues/open/iss-2609300651115290-rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an.md b/.abcd/work/issues/open/iss-2609300651115290-rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an.md new file mode 100644 index 000000000..24a11b4a1 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300651115290-rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300651115290" +slug: "rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/payload.go" +remedy: "Count as naming IFS any declaration, read, mapfile, getopts or let word, any printf -v or wait -p target, and any assignment-shaped word whose name holds an expansion's mark, plus an arithmetic expression that names IFS or assigns through an expansion; fail closed. Grounds: bash 3.2, /bin/sh, dash and bash 5.3 split ${U:-x/x} into an empty field and / after each form." +--- + +rm-rf-root-or-home is bypassed by an IFS named through an expansion: with I=I, `export ${I}FS=x; rm -rf ${U:-x/x}` allows and hands rm "" and / on bash 3.2, /bin/sh, dash and bash 5.3, and so do `eval "I${F:-F}S=x"`, `declare|typeset|readonly|local ${I}FS=x`, `read -r ${I}FS`, `printf -v ${I}FS x` and `: $((IFS=1))`. namesIFS matched the literal text IFS only, and the tokenizer steps over arithmetic. diff --git a/.abcd/work/issues/open/iss-2609300651122268-rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md b/.abcd/work/issues/open/iss-2609300651122268-rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md new file mode 100644 index 000000000..cec9b58de --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300651122268-rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300651122268" +slug: "rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "Read a positional or special parameter's slice or part (${@:2}, ${*:2}, ${1:2}) as the parameters it prints, as \"$2\" is read, rather than as a variable's substring that can be the root. Grounds: bash 3.2, /bin/sh and bash 5.3 print the later arguments for \"${@:2}\"; dash has no slice." +--- + +rm-rf-root-or-home over-blocks a positional slice: `rm -rf "${@:2}"`, `"${@:1}"`, `${@:2}`, `"${*:2}"` and `"${1:2}"` block, though each prints what the parameters hold, as `rm -rf "$2"` (allowed) does. The substring reading, which adds the root, reached positional and special parameters. diff --git a/.abcd/work/issues/open/iss-2609300651127327-rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf.md b/.abcd/work/issues/open/iss-2609300651127327-rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf.md new file mode 100644 index 000000000..e474e0aaa --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300651127327-rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300651127327" +slug: "rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf" +severity: "major" +category: "bug" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "Read a colon default, assignment or error message (${1:-w}, ${1:=w}, ${1:?}) without the empty text an emptyable parameter adds, and keep it under the colonless forms, where a set but empty parameter prints its empty value. Grounds: bash 3.2, /bin/sh, dash and bash 5.3 print dist/ for ${1:-dist}/ with no argument or an empty one." +--- + +rm-rf-root-or-home over-blocks an everyday clean line: `rm -rf "${1:-build}"/*`, `rm -rf ${1:-dist}/`, `rm -rf "${1:-dist}/"*` and `rm -rf ${@:-x}/` block, and `rm -rf ./${1:-dist}` warns, though no shell prints the empty value under `:-`. The empty text an emptyable parameter can print was kept under the colon operators. diff --git a/.abcd/work/issues/open/iss-2609300651133651-rm-rf-root-or-home-allows-expansions-that-print-nothing.md b/.abcd/work/issues/open/iss-2609300651133651-rm-rf-root-or-home-allows-expansions-that-print-nothing.md new file mode 100644 index 000000000..c1d074710 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300651133651-rm-rf-root-or-home-allows-expansions-that-print-nothing.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300651133651" +slug: "rm-rf-root-or-home-allows-expansions-that-print-nothing" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "Read an empty default word as the empty text, and a subscript alone, a case change and an @ transform as the value or nothing; drop the empty text wherever ifsSplits reads a site, since it splits into no field. Grounds: bash 3.2, /bin/sh and bash 5.3 print / for ${X:-}/, ${X-}/, ${A[0]}/ and ${A[@]}/ with X and A unset, and bash 5.3 for ${X^}/ and ${X@P}/ with X empty." +--- + +rm-rf-root-or-home allows expansions that print nothing beside a root: `rm -rf ${X:-}/`, `${X-}/`, `$HOME${X:-}`, `${A[0]}/`, `${A[@]}/`, and bash 5 `${X^}/` and `${X@P}/` hand rm / or the home, while `${1:-}/` blocks. spellWord read an empty word as no text, and the subscript, case and @ operators returned the value alone. From 3c68b005ca408b3a2cb07e3e35c5fc85894fee62 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:53:55 +0100 Subject: [PATCH 19/69] fix(guard): count an IFS named through an expansion namesIFS matched the literal text IFS, so a name built by an expansion (export ${I}FS=x, read -r ${I}FS, printf -v ${I}FS x, eval "I${F:-F}S=x") set IFS unseen, and an unquoted default word split into the root. A word whose assigned name holds an expansion's mark now counts after a declaration, read, mapfile, getopts or let, as a printf -v or wait -p target, or as an assignment-shaped word; the tokenizer also raises segment.arithmeticAssigns for an arithmetic body that names IFS or assigns through an expansion (: $((IFS=1))), which it steps over. splitAfterIFS reads a naming through the same function. The brief's residual drops declare $(echo I)FS=x, which is now read. Refs: iss-2609300651115290 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/17-guard.md | 8 +- commands/guard.md | 2 +- internal/core/guard/guardset_test.go | 56 +++++++++++++ internal/core/guard/payload.go | 78 +++++++++++++++++-- internal/core/guard/tokenize.go | 47 +++++++++++ 5 files changed, 183 insertions(+), 8 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/17-guard.md b/.abcd/development/brief/04-surfaces/17-guard.md index fcb764327..e0fff57ed 100644 --- a/.abcd/development/brief/04-surfaces/17-guard.md +++ b/.abcd/development/brief/04-surfaces/17-guard.md @@ -347,7 +347,10 @@ background, `$@`, `$*` and a positional one with no argument, `$_` after `"${1}"/` and `/$!` are `/`), and `$!` in a pattern as text of any length (`${PWD%%$!*}` is `${PWD%%*}`); a replacement's pattern is read both where bash 3.2 ends it and where bash 5 does, at a quoted `/` -(`${X/"/"*/$HOME}`); and on a line that names IFS, an unquoted default's or +(`${X/"/"*/$HOME}`); and on a line that names IFS — in any word, in a +declaration's, a `read`'s or a `printf -v`'s name or an assignment's name +built by an expansion (`export ${I}FS=x`, `eval "I${F:-F}S=x"`), or in an +arithmetic expression (`: $((IFS=1))`) — an unquoted default's or alternative's word, and an unquoted home, reads as every target, since the fields bash splits it into rest on that IFS (`IFS=x; rm -rf ${U:-x/x}`, `IFS=Uv; rm -rf $HOME/x`). A run of `/` written before the home names the @@ -412,7 +415,8 @@ prints the root (`${PWD:0:1}`), which warns as `$PWD` does; one behind a wrapper table does not name; a REST path an entry names by its root segment when the host serves that API under a prefix; an IFS the shell already holds when the line starts, or gains during the line -through a name the guard does not read (`declare $(echo I)FS=x`, a sourced file), +through a name the guard does not read (a sourced file, a nameref set before +the line), since every line is read from the default IFS; a pid list a kill reads through a variable or a file, or from a `ps | grep` chain; a payload inside a non-shell interpreter such as `python -c`, which is diff --git a/commands/guard.md b/commands/guard.md index 5560c60b0..2840a6186 100644 --- a/commands/guard.md +++ b/commands/guard.md @@ -390,7 +390,7 @@ runs, or `pkill` or `killall` as the program a variable names (`$P make`) — because reading each would refuse the ordinary commands a variable carries a value for, an IFS the shell already holds when the line starts or gains during the line through a name the guard does not read -(`declare $(echo I)FS=x`, a sourced file; every line is read from the default IFS), a hazard inside a non-shell interpreter's payload (`python -c`, +(a sourced file, a nameref set before the line; every line is read from the default IFS), a hazard inside a non-shell interpreter's payload (`python -c`, `perl -e`) — one opaque token the tokenizer cannot read, today a silent allow, not a warn (a warn for it is a recorded design target, not yet implemented), or a dangerous form no entry describes. Nor does an allow see what a lone substitution diff --git a/internal/core/guard/guardset_test.go b/internal/core/guard/guardset_test.go index 2ab108d68..f2b724dd2 100644 --- a/internal/core/guard/guardset_test.go +++ b/internal/core/guard/guardset_test.go @@ -232,3 +232,59 @@ func TestParametersThatPrintNothingTheWrittenCompareReads(t *testing.T) { {`IFS=, ; rm -rf $_`, shellBare | shellSQ, VerdictAllow, ""}, }) } + +// TestIFSNamedThroughAMarkTheWrittenCompareReads — reverify-guardSet finding +// 1. An IFS can be named through a word that holds an expansion: +// `export ${I}FS=x`, `declare I${F}FS=x`, `read -r ${I}FS`, +// `printf -v ${I}FS x`, and `eval "I${F:-F}S=x"`, whose string the eval +// runs as an assignment. With I=I and F=F each gives IFS the value x, and +// `rm -rf ${U:-x/x}` then hands rm `""` and `/` on bash 3.2, /bin/sh, dash +// and bash 5.3. The guard does not spell the name, so a declaration's word, +// a `read` or `printf -v` target, and an assignment word whose name holds an +// expansion count as naming IFS, and so does an arithmetic expression that +// names IFS or assigns through an expansion (`: $((IFS=1))`). +func TestIFSNamedThroughAMarkTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + checkSpellingCases(t, []spellingCase{ + {`I=I; export ${I}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`export I${F}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`export "${I}FS"=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`declare ${I}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`declare -x ${I}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`typeset ${I}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`readonly ${I}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`local ${I}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`declare $(echo I)FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`read -r ${I}FS <<< x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`printf -v ${I}FS x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`printf -v "$n" x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`eval "I${F:-F}S=x"; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`eval I${F}FS=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`I${F:-F}S=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`eval "${I}FS=x"; eval rm -rf ${U:-x/x}`, shellBare, VerdictBlock, home}, + {`let ${I}FS=1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`getopts a ${I}FS; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`mapfile -t ${I}FS < f; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`wait -p ${I}FS; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + // An arithmetic assignment leaves no word to read the name in. + {`: $((IFS=1)); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`(( IFS=1 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`: $((${I}FS=1)); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${U:-1/1}; echo "$((IFS=1))"`, shellBare | shellSQ, VerdictBlock, home}, + {`: $((${I}FS<<=1)); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + // The look-alikes: a declaration, a read and a printf whose names are + // written, an assignment whose value (not its name) holds an + // expansion, and the everyday reads with a quoted operand. + {`export PATH=$HOME/bin:$PATH; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`declare -a files; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`read -r f; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`printf '%s\n' "$x"; rm -rf ${U:-x/x}`, shellBare, VerdictAllow, ""}, + {`printf -v out '%s' "$x"; rm -rf ${U:-x/x}`, shellBare, VerdictAllow, ""}, + {`OUT=$x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`n=$((n+1)); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`(( $n == 1 )) && rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`: $(($n <= 1)); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS= read -r f; rm -rf "$f"`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS=, read -ra arr <<< "$x"; rm -rf "${arr[0]}"`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} diff --git a/internal/core/guard/payload.go b/internal/core/guard/payload.go index db99a3fca..f89707a68 100644 --- a/internal/core/guard/payload.go +++ b/internal/core/guard/payload.go @@ -495,18 +495,89 @@ func wordFeeds(s segment, keep func(int) bool) []feed { // namesIFS reports whether any word of segs names IFS, as splitAfterIFS // reads a naming: an assignment in a command of its own, an `export`, a // `read`, a prefix assignment, or any other word that holds the name. +// +// A name can also be built by an expansion the guard does not spell +// (reverify-guardSet finding 1): with I=I, `export ${I}FS=x`, +// `read -r ${I}FS`, `printf -v ${I}FS x` and `eval "I${F:-F}S=x"` each set +// IFS. So a word holding an expansion's mark counts where it can be a name +// a command assigns: any word after a command that assigns the names it is +// handed (namingCommands, read wherever it stands in the command), the +// word after `printf -v` or `wait -p`, and any assignment-shaped word whose +// name holds the mark (markedAssignment), which an eval or a shell handed +// the string runs as an assignment. That reading refuses on the side of a +// name: `read -p "$prompt" f` counts too. An arithmetic expression the +// tokenizer steps over names IFS through segment.arithmeticAssigns +// (`: $((IFS=1))`). func namesIFS(segs []segment) bool { for _, s := range segs { + if s.arithmeticAssigns { + return true + } + naming, target := false, false + flag := "" for _, tok := range s.tokens { tally(len(tok)) if strings.Contains(tok, "IFS") { return true } + if nameMarked(tok) && (naming || target || markedAssignment(tok) || flag != "" && strings.HasPrefix(tok, flag)) { + return true + } + target = flag != "" && tok == flag + if namingCommands[tok] { + naming = true + } + if f, ok := targetFlags[tok]; ok { + flag = f + } } } return false } +// namingCommands are the builtins that assign a variable each name they are +// handed names: a declaration (`export ${I}FS=x`), `read`, `mapfile` and +// `readarray`, `getopts`'s name, and `let`'s expressions. +var namingCommands = map[string]bool{ + "export": true, "declare": true, "typeset": true, "readonly": true, "local": true, + "read": true, "mapfile": true, "readarray": true, "getopts": true, "let": true, +} + +// targetFlags names, per builtin, the flag whose word assigns the variable +// it names: `printf -v NAME`, and bash 5.1's `wait -p NAME`. +var targetFlags = map[string]string{"printf": "-v", "wait": "-p"} + +// nameMarked reports whether the name tok would assign holds an +// expansion's mark: its text before the first `=`, or all of it where it +// has none. `PATH=$HOME/bin` names PATH, which the line spells. +func nameMarked(tok string) bool { + if eq := strings.IndexByte(tok, '='); eq >= 0 { + tok = tok[:eq] + } + return strings.IndexByte(tok, unknownMark) >= 0 || strings.IndexByte(tok, varMark) >= 0 +} + +// markedAssignment reports whether tok is shaped as an assignment +// (`NAME=value`, `NAME+=value`) whose name holds an expansion's mark: +// `I${F:-F}S=x` in the string an eval runs is `IFS=x` there. +func markedAssignment(tok string) bool { + eq := strings.IndexByte(tok, '=') + if eq <= 0 { + return false + } + name := strings.TrimSuffix(tok[:eq], "+") + marked := false + for i := 0; i < len(name); i++ { + switch c := name[i]; { + case c == unknownMark || c == varMark: + marked = true + case !isNameByte(c): + return false + } + } + return marked +} + // capIFSSplits reads each word of segs whose fields rest on the default IFS // (segment.ifsSplit) as a spelling past its bound (spellCapped), which the // arg_values compare reads as every value (review-guardSet MAJOR-2). It runs @@ -554,11 +625,8 @@ func splitAfterIFS(segs []segment) bool { if s.fromFixedOutput || (carriers == 1 && carrying[i]) { continue } - for _, tok := range s.tokens { - tally(len(tok)) - if strings.Contains(tok, "IFS") { - return true - } + if namesIFS(segs[i : i+1]) { + return true } } return false diff --git a/internal/core/guard/tokenize.go b/internal/core/guard/tokenize.go index 756d013ca..7543c42a9 100644 --- a/internal/core/guard/tokenize.go +++ b/internal/core/guard/tokenize.go @@ -92,6 +92,12 @@ type segment struct { // the token, where the variable is the unknown word's mark // (iss-2609290321312087). nil when no word holds a variable. spelled map[int][]string + // arithmeticAssigns records that the line holds an arithmetic + // expression that can assign IFS (arithmeticNamesIFS): `: $((IFS=1))` + // sets it, and the expression leaves no word to read the name in. It + // rides on an empty segment of its own, as substitutionUnread does, and + // namesIFS reads it. + arithmeticAssigns bool // ifsSplit records, per token index, a spelled word whose fields rest on // the default IFS (ifsSplits in unknown.go), which a line that names IFS // reads as past its bound (capIFSSplits). nil when no word is. @@ -544,6 +550,9 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // could not read, on an empty segment of its own. Once per call is enough: // the verdict is the whole command's. unreadRaised := false + // arithIFS records an arithmetic body that can assign IFS + // (segment.arithmeticAssigns), raised once, when the input ends. + arithIFS := false unread := func() { if !unreadRaised { unreadRaised = true @@ -834,6 +843,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // substitution inside it runs, so each one is followed. arithmetic := func(body string) { tally(len(body)) + arithIFS = arithIFS || arithmeticNamesIFS(body) for j := 0; j < len(body); { switch { case body[j] == '\\': @@ -1512,6 +1522,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { i++ break } + arithIFS = arithIFS || arithmeticNamesIFS(line[i+3:end-1]) openSubstitution(parenArithExp, i, false) parens[len(parens)-1].end = end lastList = false @@ -1596,6 +1607,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { unread() } if end >= 0 { + arithIFS = arithIFS || arithmeticNamesIFS(line[i+2:end-1]) openSubstitution(parenArithExp, i, false) parens[len(parens)-1].end = end parens[len(parens)-1].bare = true @@ -1755,9 +1767,44 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { if len(pending) > 0 { markHeredocUnterminated(&segs, chain) } + if arithIFS { + segs = append(segs, segment{chain: chain, arithmeticAssigns: true}) + } return segs, nil } +// arithmeticNamesIFS reports whether an arithmetic expression's body can +// assign IFS: it names IFS, or it assigns (`=`, `+=`, `<<=`, never `==`, +// `!=`, `<=` or `>=`) and holds an expansion that can build the name +// (`$((${I}FS=1))`). bash 3.2, /bin/sh, dash and bash 5.3 split +// `${U:-1/1}` into `""` and `/` after `: $((IFS=1))`. +func arithmeticNamesIFS(body string) bool { + tally(len(body)) + if strings.Contains(body, "IFS") { + return true + } + if !strings.ContainsAny(body, "$`") { + return false + } + for i := 0; i < len(body); i++ { + if body[i] != '=' { + continue + } + if i+1 < len(body) && body[i+1] == '=' { + i++ + continue + } + if i == 0 || strings.IndexByte("=!<>", body[i-1]) < 0 { + return true + } + if i >= 2 && body[i-2] == body[i-1] && body[i-1] != '=' && body[i-1] != '!' { + // `<<=` and `>>=` assign. + return true + } + } + return false +} + // arithmeticOperand is the word an arithmetic expansion leaves in its // command: a number, which is all `$(( … ))` can print. Its value is not // modelled, and needs not be: no flag, subcommand or path an entry names is a From 8b6ddeb185a802ac8fe0d5f9d9ff0d4296ef9340 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:53:57 +0100 Subject: [PATCH 20/69] fix(guard): read a colon default without the empty value A colon default, assignment or error message treats an empty parameter as unset, so ${1:-dist} with no argument prints dist, never nothing. The empty text an emptyable parameter adds is dropped under :-, := and :? and kept under -, = and ?, where a set but empty parameter prints it. An indirection keeps reading as every value past either form. Refs: iss-2609300651127327 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/17-guard.md | 3 +- internal/core/guard/guardset_test.go | 36 +++++++++++++++++++ internal/core/guard/unknown.go | 29 +++++++++------ 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/17-guard.md b/.abcd/development/brief/04-surfaces/17-guard.md index e0fff57ed..1285da2d5 100644 --- a/.abcd/development/brief/04-surfaces/17-guard.md +++ b/.abcd/development/brief/04-surfaces/17-guard.md @@ -326,7 +326,8 @@ variables its text holds, and a name runs on into the letters a list or a sequence places after it (`{$HOME,x}`, `$HOME/{.*,}`, `$HO{ME,}`, `$HO{M..M}E`); an expansion whose operator can leave the value as it is reads as the variable itself — a default, an assignment or an error message -(`${HOME:-x}`), a trim or a pattern replacement (`${HOME%/}`, `${HOME#x}`, +(`${HOME:-x}`), where the colon forms never print an empty value +(`${1:-dist}/` is `${1}/` or `dist/`), a trim or a pattern replacement (`${HOME%/}`, `${HOME#x}`, `${HOME/x/y}`), any substring, which also reads as the root and as nothing (`${X:1}`, `${PWD:0:1}`), a case change, and a subscript read to its matching `]` with any text after it (`${HOME[x[0]]}`, `${HOME[0]]}`, which the diff --git a/internal/core/guard/guardset_test.go b/internal/core/guard/guardset_test.go index f2b724dd2..7a55394c3 100644 --- a/internal/core/guard/guardset_test.go +++ b/internal/core/guard/guardset_test.go @@ -288,3 +288,39 @@ func TestIFSNamedThroughAMarkTheWrittenCompareReads(t *testing.T) { {`IFS=, read -ra arr <<< "$x"; rm -rf "${arr[0]}"`, shellBare | shellSQ, VerdictAllow, ""}, }) } + +// TestColonDefaultsOfAnEmptyParameterTheWrittenCompareReads — +// reverify-guardSet finding 2. With the colon, a default, an assignment and +// an error message treat an empty parameter as unset, so `${1:-dist}` with +// no argument, or an empty one, prints `dist`, never nothing: bash 3.2, +// /bin/sh, dash and bash 5.3 hand rm `dist/` for `${1:-dist}/`. Without the +// colon a set but empty parameter prints its value, nothing (`${1-dist}/` is +// `/` after `set -- ""`). +func TestColonDefaultsOfAnEmptyParameterTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf "${1:-build}"/*`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${1:-dist}/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${1:-dist}/"*`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${@:-x}/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${1:=dist}/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${1:?}/`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ./${1:-dist}`, shellBare | shellSQ, VerdictAllow, ""}, + // In a string the outer shell expands, `${1:-dist}` hands the inner + // shell `${1}`'s text, which it reads as a parameter of its own that + // can print nothing: `sh -c "rm -rf ${1:-dist}/"` refuses, a + // fail-closed over-read. + // The block forms: a default that is the root, an empty or + // colonless default, and an error message without the colon. + {`rm -rf ${1:-/}`, all, VerdictBlock, home}, + {`rm -rf ${1-}/`, all, VerdictBlock, home}, + {`rm -rf ${1-dist}/`, all, VerdictBlock, home}, + {`rm -rf ${1=dist}/`, all, VerdictBlock, home}, + {`rm -rf ${@-x}/`, all, VerdictBlock, home}, + {`rm -rf ${1?}/`, all, VerdictBlock, home}, + // An indirection past a colon default still reads as every value. + {`rm -rf ${!X:-dist}/`, all, VerdictBlock, home}, + {`rm -rf ${!X:?}/`, all, VerdictBlock, home}, + }) +} diff --git a/internal/core/guard/unknown.go b/internal/core/guard/unknown.go index 0d10eb3fd..0b243503f 100644 --- a/internal/core/guard/unknown.go +++ b/internal/core/guard/unknown.go @@ -339,9 +339,13 @@ func paramText(text string) string { return strings.ReplaceAll(text, "\\\n", "") // `${DIR=w}`), prints the value when the variable is set and its word w // when it is not, so the set holds the variable and every text w can // print, through its own expansions (spellWord): `${DIR:-$HOME}` is -// `${DIR}` and `$HOME` (iss-2609290426544292); +// `${DIR}` and `$HOME` (iss-2609290426544292); with the colon an empty +// value counts as unset, so a +// parameter that can print nothing (emptyable) does not print it there +// (`${1:-dist}` is `${1}` and `dist`); // - an error message (`${HOME:?x}`) prints the value or nothing: the -// message goes to the standard error, never into the word; +// message goes to the standard error, never into the word, and with the +// colon an empty value is an error, as above; // - a trimmed prefix or suffix and a pattern replacement (`${HOME%/}`, // `${HOME#x}`, `${HOME/x/y}`): the value when the pattern does not match, // and what a suffix trim leaves otherwise is the path above it. What @@ -436,10 +440,14 @@ func spellParameterAt(body string, depth int, split, empty bool) []string { name, rest := body[:n], body[n:] same := "${" + name + "}" value := []string{same} + // set is the value as the colon forms read it (`${1:-w}`, `${1:=w}`, + // `${1:?}`): an empty parameter counts as unset there, so the + // expansion never prints the empty value (reverify-guardSet finding 2). + set := value if empty && !indirect && emptyable(name) { // `${!}`, `${@}`, `${1}` can print nothing (emptyable), and every - // operator reads that nothing as it reads a value. - value = append(value, "") + // other operator reads that nothing as it reads a value. + value = append([]string{same}, "") } if indirect { // An indirection's value is the value of the variable its name @@ -451,10 +459,11 @@ func spellParameterAt(body string, depth int, split, empty bool) []string { return raw } value = []string{spellCapped} + set = value } - // orWord is the value, or the texts the word w can print. - orWord := func(w string) []string { - texts := value + // orWord is the value from, or the texts the word w can print. + orWord := func(from []string, w string) []string { + texts := append([]string(nil), from...) for _, t := range spellWord(w, depth, split) { texts = appendText(texts, t) } @@ -500,11 +509,11 @@ func spellParameterAt(body string, depth int, split, empty bool) []string { case strings.HasPrefix(rest, ":+"): return alternative(rest[2:]) case rest[0] == '-' || rest[0] == '=': - return orWord(rest[1:]) + return orWord(value, rest[1:]) case strings.HasPrefix(rest, ":-") || strings.HasPrefix(rest, ":="): - return orWord(rest[2:]) + return orWord(set, rest[2:]) case strings.HasPrefix(rest, ":?"): - return value + return set } var texts []string switch { From 97f1d7a3173afc5a3a8348a735452d348c368543 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:53:59 +0100 Subject: [PATCH 21/69] fix(guard): read an expansion that can print nothing as nothing too An empty default word (${X:-}, ${X-}) now prints the empty text, and a subscript alone, a case change and an @ transform read as the value or nothing, so ${X:-}/, ${A[0]}/, ${X^}/ and ${X@P}/ read as the root, as ${1:-}/ already did. ifsSplits drops the empty text of every site, since it splits into no field under any IFS; that replaces varSite.empty and emptiedParameter, which dropped it for an emptyable parameter only. Refs: iss-2609300651133651 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/17-guard.md | 10 +- internal/core/guard/guardset_test.go | 38 +++++++ internal/core/guard/tokenize.go | 5 +- internal/core/guard/unknown.go | 99 ++++++++----------- 4 files changed, 87 insertions(+), 65 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/17-guard.md b/.abcd/development/brief/04-surfaces/17-guard.md index 1285da2d5..3a43fdc85 100644 --- a/.abcd/development/brief/04-surfaces/17-guard.md +++ b/.abcd/development/brief/04-surfaces/17-guard.md @@ -327,11 +327,13 @@ sequence places after it (`{$HOME,x}`, `$HOME/{.*,}`, `$HO{ME,}`, `$HO{M..M}E`); an expansion whose operator can leave the value as it is reads as the variable itself — a default, an assignment or an error message (`${HOME:-x}`), where the colon forms never print an empty value -(`${1:-dist}/` is `${1}/` or `dist/`), a trim or a pattern replacement (`${HOME%/}`, `${HOME#x}`, +(`${1:-dist}/` is `${1}/` or `dist/`) and an empty word prints the empty text +(`${X:-}/` is also `/`), a trim or a pattern replacement (`${HOME%/}`, `${HOME#x}`, `${HOME/x/y}`), any substring, which also reads as the root and as nothing -(`${X:1}`, `${PWD:0:1}`), a case change, and a subscript read to its -matching `]` with any text after it (`${HOME[x[0]]}`, `${HOME[0]]}`, which the -bash 3.2 of macOS prints as the value); and an alternative, which prints its +(`${X:1}`, `${PWD:0:1}`), a case change or a transform (`${X^}`, `${X@P}`), +and a subscript read to its matching `]` with any text after it +(`${HOME[x[0]]}`, `${HOME[0]]}`, which the bash 3.2 of macOS prints as the +value), the last two also as nothing (`${A[0]}/` is also `/`); and an alternative, which prints its word or nothing, reads as that word as written (`${X:+$HOME}`, `${X:+/}`, `${X:+$HOME/*}`), including one the bash 3.2 of macOS reads at the first operator after a subscript (`${X[0]]:+$HOME}`). Unquoted, the alternative's diff --git a/internal/core/guard/guardset_test.go b/internal/core/guard/guardset_test.go index 7a55394c3..521ff12f3 100644 --- a/internal/core/guard/guardset_test.go +++ b/internal/core/guard/guardset_test.go @@ -324,3 +324,41 @@ func TestColonDefaultsOfAnEmptyParameterTheWrittenCompareReads(t *testing.T) { {`rm -rf ${!X:?}/`, all, VerdictBlock, home}, }) } + +// TestExpansionsThatCanPrintNothingTheWrittenCompareReads — +// reverify-guardSet finding 5. An empty default's word prints the empty +// text (`${X:-}/` and `${X-}/` are `/` with X unset), a subscript can name +// an element that is not set (`${A[0]}/`, `${A[@]}/` with A unset or a +// scalar's `${A[1]}`), and a case change or a transform prints nothing for +// a value it maps to nothing (`${X^}/`, `${X@P}/` on bash 5.3 with X +// empty). bash 3.2, /bin/sh and bash 5.3 hand rm `/` for each. +func TestExpansionsThatCanPrintNothingTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf ${X:-}/`, all, VerdictBlock, home}, + {`rm -rf ${X-}/`, all, VerdictBlock, home}, + {`rm -rf ${X:=}/`, all, VerdictBlock, home}, + {`rm -rf ${X:+}/`, all, VerdictBlock, home}, + {`rm -rf ${X:-""}/`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X:-''}/`, shellBare, VerdictBlock, home}, + {`rm -rf $HOME${X:-}`, all, VerdictBlock, home}, + {`rm -rf ${A[0]}/`, all, VerdictBlock, home}, + {`rm -rf ${A[@]}/`, all, VerdictBlock, home}, + {`rm -rf "${A[1]}"/*`, shellBare | shellSQ, VerdictBlock, home}, + {`rm -rf ${X^}/`, all, VerdictBlock, home}, + {`rm -rf ${X^^}/`, all, VerdictBlock, home}, + {`rm -rf ${X,,}/`, all, VerdictBlock, home}, + {`rm -rf ${X@P}/`, all, VerdictBlock, home}, + {`rm -rf ${X@U}/`, all, VerdictBlock, home}, + // The look-alikes: text after the empty text, a quoted array, and + // an error message. + {`rm -rf "${TMPDIR:-}/abcd-x"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${A[0]}/build"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${files[@]}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${X^^}.txt`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf "${BUILD_DIR:?}/"*`, shellBare | shellSQ, VerdictAllow, ""}, + {`while IFS= read -r f; do rm -rf ${f:-}; done`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS=, ; rm -rf ${A[0]}`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} diff --git a/internal/core/guard/tokenize.go b/internal/core/guard/tokenize.go index 7543c42a9..4d9f48ecc 100644 --- a/internal/core/guard/tokenize.go +++ b/internal/core/guard/tokenize.go @@ -641,9 +641,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // text is text (`$HOME`, `$1`), with the texts it can print // (paramTexts). addParam := func(text string) { - texts := paramTexts(text) - addVar(texts...) - curVarAt[len(curVarAt)-1].empty = len(texts) > 1 + addVar(paramTexts(text)...) } // addBang keeps a `$!` in the word as the text it is, which every // reading takes as the job's number (`kill $!`), and records a site on @@ -1078,7 +1076,6 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { addVar(spellParameter(body, split)...) site := &curVarAt[len(curVarAt)-1] site.split = split - site.empty = emptiedParameter(body, split, site.texts) if len(segs) > start { curSub = true } diff --git a/internal/core/guard/unknown.go b/internal/core/guard/unknown.go index 0b243503f..77f01704e 100644 --- a/internal/core/guard/unknown.go +++ b/internal/core/guard/unknown.go @@ -112,10 +112,6 @@ const varText = "\x01" // split records an expansion written unquoted, whose text bash splits into // fields on IFS (ifsSplits). // -// empty records that the texts hold the empty text only because the -// parameter can print nothing (emptyable), which splits into no field and -// so is not counted where ifsSplits reads the site. -// // width is the number of bytes of the word the site stands on where the // word keeps the text as written rather than a mark (`$!`, which every // other reading takes as the job's number it is, addBang), and 0 for a @@ -125,7 +121,6 @@ type varSite struct { texts []string bare bool split bool - empty bool width int } @@ -166,16 +161,15 @@ func paramTexts(text string) []string { // the fields can be the root (`IFS=x; rm -rf ${U:-x/x}` hands rm `""` and // `/`; `IFS=Uv; rm -rf $HOME/x` hands it `/`). An unquoted variable of // unknown value (`$d`, `${d%/}`) splits into fields no more known than its -// value, and is not counted. +// value, and is not counted. The empty text an expansion can print splits +// into no field under any IFS, and is not read: `${1}`, `${X:-}` and +// `${A[0]}` count as the variable alone. func ifsSplits(sites []varSite) bool { for _, s := range sites { - texts := s.texts - if s.empty { - texts = nil - for _, t := range s.texts { - if t != "" { - texts = append(texts, t) - } + var texts []string + for _, t := range s.texts { + if t != "" { + texts = append(texts, t) } } if !s.split || len(texts) == 0 { @@ -339,8 +333,8 @@ func paramText(text string) string { return strings.ReplaceAll(text, "\\\n", "") // `${DIR=w}`), prints the value when the variable is set and its word w // when it is not, so the set holds the variable and every text w can // print, through its own expansions (spellWord): `${DIR:-$HOME}` is -// `${DIR}` and `$HOME` (iss-2609290426544292); with the colon an empty -// value counts as unset, so a +// `${DIR}` and `$HOME` (iss-2609290426544292), and `${DIR:-}` is also +// the empty text; with the colon an empty value counts as unset, so a // parameter that can print nothing (emptyable) does not print it there // (`${1:-dist}` is `${1}` and `dist`); // - an error message (`${HOME:?x}`) prints the value or nothing: the @@ -359,7 +353,8 @@ func paramText(text string) string { return strings.ReplaceAll(text, "\\\n", "") // past the end, and which can print the `/` an absolute path begins with // (`${PWD:0:1}` is `${PWD}`, `/` and nothing); // - a case change (`${HOME^^}`, `${HOME@U}`), which names the same directory -// on a case-insensitive disk, and `@E` and `@P`, which change no path; +// on a case-insensitive disk, and `@E` and `@P`, which change no path, +// each also nothing, for a value it maps to nothing (`${X^}/` is `/`); // - a subscript (`${HOME[0]}`, `${HOME[x[0]]}`), which can be 0, read to // its matching `]`. bash 3.2, the /bin/sh and /bin/bash of macOS, steps // over any text after it to the first operator byte @@ -369,7 +364,9 @@ func paramText(text string) string { return strings.ReplaceAll(text, "\\\n", "") // after a name and also nothing, which is what bash 3.2 prints for one // after a scalar's subscript (`${X[0]%x}` with X=/a/b; // iss-2609300009506126), and anything else the value (`${HOME[0]]}`, -// `${HOME[0]@Q}`). A subscript with no `]` cannot be read further. +// `${HOME[0]@Q}`), and alone the value or nothing, since the element +// it names need not be set (`${A[0]}/` is `/`). A subscript with no `]` +// cannot be read further. // // An alternative (`${X:+w}`, `${X+w}`) prints w or nothing, and is the texts // w can print and the empty text. Every other expansion keeps its text as written and names no @@ -381,7 +378,7 @@ func paramText(text string) string { return strings.ReplaceAll(text, "\\\n", "") // run in it is spelled fieldMark, which the compare splits on // (argValueMatches). func spellParameter(body string, split bool) []string { - return spellParameterAt(paramText(body), 0, split, true) + return spellParameterAt(paramText(body), 0, split) } // fieldMark stands in a spelling where bash splits a word into fields: at an @@ -403,35 +400,8 @@ const quotedFieldMark = '\x03' // quotedFieldText is quotedFieldMark as a string. const quotedFieldText = "\x03" -// emptiedParameter reports whether texts, the written spelling of the -// `${…}` whose text between the braces is body, hold the empty text only -// because its parameter can print nothing (varSite.empty): read without that -// reading, the expansion prints no empty text. -func emptiedParameter(body string, split bool, texts []string) bool { - body = paramText(body) - if indirect, n := paramNameEnd(body); !split || indirect || n < 0 || !emptyable(body[:n]) { - // Only a split site is counted, and only an emptyable parameter - // adds the empty text. - return false - } - has := false - for _, t := range texts { - has = has || t == "" - } - if !has { - return false - } - for _, t := range spellParameterAt(body, 0, split, false) { - if t == "" { - return false - } - } - return true -} - -// spellParameterAt is spellParameter at depth, with empty reporting whether -// a parameter that can print nothing (emptyable) is read as printing it. -func spellParameterAt(body string, depth int, split, empty bool) []string { +// spellParameterAt is spellParameter at depth. +func spellParameterAt(body string, depth int, split bool) []string { raw := []string{"${" + body + "}"} indirect, n := paramNameEnd(body) if n < 0 { @@ -444,11 +414,18 @@ func spellParameterAt(body string, depth int, split, empty bool) []string { // `${1:?}`): an empty parameter counts as unset there, so the // expansion never prints the empty value (reverify-guardSet finding 2). set := value - if empty && !indirect && emptyable(name) { + if !indirect && emptyable(name) { // `${!}`, `${@}`, `${1}` can print nothing (emptyable), and every // other operator reads that nothing as it reads a value. value = append([]string{same}, "") } + // orNothing is the value, or nothing: a subscript naming an element + // that is not set (`${A[1]}` of a scalar), and a case change or a + // transform that maps the value to nothing (`${X^}`, `${X@P}` with X + // empty), leave the text beside them (reverify-guardSet finding 5). + orNothing := func() []string { + return appendText(append([]string(nil), value...), "") + } if indirect { // An indirection's value is the value of the variable its name // holds, which the line does not spell: past an operator that can @@ -496,7 +473,7 @@ func spellParameterAt(body string, depth int, split, empty bool) []string { // `${!A[@]}` lists the array's keys. return raw } - return value + return orNothing() } rest, subscript = rest[op:], true } @@ -527,13 +504,16 @@ func spellParameterAt(body string, depth int, split, empty bool) []string { case rest[0] == '%' || rest[0] == '#': texts = trimTexts(value, rest) case subscript: + return orNothing() + case rest[0] == '?': + // An error message without the colon prints the value. return value - case strings.IndexByte("?^,~", rest[0]) >= 0: - // Every other operator that can print the value unchanged. - return value + case strings.IndexByte("^,~", rest[0]) >= 0: + // A case change prints the value, changed, or nothing. + return orNothing() case rest[0] == '@': if len(rest) == 2 && strings.IndexByte("EPULu", rest[1]) >= 0 { - return value + return orNothing() } return raw default: @@ -997,8 +977,8 @@ func subscriptEnd(s string) int { // spellWritten drops as knownText does (`${X:+$(true)$HOME}` is `$HOME`). // Where split is set, each unquoted whitespace run is fieldMark, where bash // splits the word (`${X:+$HOME }` is `$HOME`). A word holding a quote or an -// expansion that does not close, and a word that spells to nothing, print no -// text the guard reads, and are nil. A word at spellWordDepth is not read, +// expansion that does not close prints no text the guard reads, and is nil; +// an empty word (`${X:-}`, `${X:-""}`) prints the empty text. A word at spellWordDepth is not read, // and is spellCapped: the bound refuses, never passes (writtenMatches). func spellWord(w string, depth int, split bool) []string { if depth >= spellWordDepth { @@ -1034,7 +1014,7 @@ func spellWord(w string, depth int, split bool) []string { if end < 0 { return nil } - sites = append(sites, varSite{at: len(word), texts: spellParameterAt(w[i+2:end], depth+1, split && !dq, true)}) + sites = append(sites, varSite{at: len(word), texts: spellParameterAt(w[i+2:end], depth+1, split && !dq)}) word = append(word, varMark) i = end + 1 case c == '$' && i+1 < len(w) && w[i+1] == '(': @@ -1100,9 +1080,14 @@ func spellWord(w string, depth int, split bool) []string { i++ } } - if dq || len(word) == 0 { + if dq { return nil } + if len(word) == 0 { + // An empty word prints the empty text: `${X:-}/` is `/` with X + // unset (reverify-guardSet finding 5). + return []string{""} + } return spellWritten(word, sites, nil) } From 9e5fb2e3c8f2683c2d5e1ff0d20c97d9d6e32f06 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:54:02 +0100 Subject: [PATCH 22/69] fix(guard): read a positional slice as the parameters it prints ${@:2}, ${*:2} and ${1:2} print what the positional parameters hold, as "$2" does, so they read as the parameter's value and nothing rather than as a variable's substring, which adds the root. Refs: iss-2609300651122268 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/17-guard.md | 12 ++++++----- internal/core/guard/guardset_test.go | 20 +++++++++++++++++++ internal/core/guard/unknown.go | 15 +++++++++++++- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/17-guard.md b/.abcd/development/brief/04-surfaces/17-guard.md index 3a43fdc85..06f132b86 100644 --- a/.abcd/development/brief/04-surfaces/17-guard.md +++ b/.abcd/development/brief/04-surfaces/17-guard.md @@ -329,11 +329,13 @@ as the variable itself — a default, an assignment or an error message (`${HOME:-x}`), where the colon forms never print an empty value (`${1:-dist}/` is `${1}/` or `dist/`) and an empty word prints the empty text (`${X:-}/` is also `/`), a trim or a pattern replacement (`${HOME%/}`, `${HOME#x}`, -`${HOME/x/y}`), any substring, which also reads as the root and as nothing -(`${X:1}`, `${PWD:0:1}`), a case change or a transform (`${X^}`, `${X@P}`), -and a subscript read to its matching `]` with any text after it -(`${HOME[x[0]]}`, `${HOME[0]]}`, which the bash 3.2 of macOS prints as the -value), the last two also as nothing (`${A[0]}/` is also `/`); and an alternative, which prints its +`${HOME/x/y}`), any substring of a variable, which also reads as the root +and as nothing (`${X:1}`, `${PWD:0:1}`), while a slice of the positional +parameters or a part of one reads as those parameters (`"${@:2}"` and +`"${1:2}"` as `"$2"`), a case change or a transform (`${X^}`, `${X@P}`), and +a subscript read to its matching `]` with any text after it (`${HOME[x[0]]}`, +`${HOME[0]]}`, which the bash 3.2 of macOS prints as the value), the last +three also as nothing (`${A[0]}/` is also `/`); and an alternative, which prints its word or nothing, reads as that word as written (`${X:+$HOME}`, `${X:+/}`, `${X:+$HOME/*}`), including one the bash 3.2 of macOS reads at the first operator after a subscript (`${X[0]]:+$HOME}`). Unquoted, the alternative's diff --git a/internal/core/guard/guardset_test.go b/internal/core/guard/guardset_test.go index 521ff12f3..e9fa27a02 100644 --- a/internal/core/guard/guardset_test.go +++ b/internal/core/guard/guardset_test.go @@ -362,3 +362,23 @@ func TestExpansionsThatCanPrintNothingTheWrittenCompareReads(t *testing.T) { {`IFS=, ; rm -rf ${A[0]}`, shellBare | shellSQ, VerdictAllow, ""}, }) } + +// TestPositionalSlicesReadAsTheParameters — reverify-guardSet finding 3. +// `${@:2}` and `${*:2}` are the arguments from the second on, and +// `${1:2}` a part of the first: each prints what the parameters hold, as +// `"$2"` and `"$1"` do, or nothing, which leaves the text beside it +// (`"${@:2}"/` is `/` with no argument). +func TestPositionalSlicesReadAsTheParameters(t *testing.T) { + const home = "rm-rf-root-or-home" + const all = shellBare | shellSQ | shellDQ + checkSpellingCases(t, []spellingCase{ + {`rm -rf "${@:2}"`, all, VerdictAllow, ""}, + {`rm -rf "${@:1}"`, all, VerdictAllow, ""}, + {`rm -rf ${@:2}`, all, VerdictAllow, ""}, + {`rm -rf "${*:2}"`, all, VerdictAllow, ""}, + {`rm -rf "${1:2}"`, all, VerdictAllow, ""}, + {`rm -rf "${@:2}"/`, all, VerdictBlock, home}, + {`rm -rf ${1:2}/`, all, VerdictBlock, home}, + {`rm -rf ${X:0:1}/`, all, VerdictBlock, home}, + }) +} diff --git a/internal/core/guard/unknown.go b/internal/core/guard/unknown.go index 77f01704e..d4087bb53 100644 --- a/internal/core/guard/unknown.go +++ b/internal/core/guard/unknown.go @@ -351,7 +351,9 @@ func paramText(text string) string { return strings.ReplaceAll(text, "\\\n", "") // `${X}` and `$HOME`; iss-2609292320015665, iss-2609300009581165); // - a substring (`${HOME:0}`), whose offset is arithmetic and can be 0 or // past the end, and which can print the `/` an absolute path begins with -// (`${PWD:0:1}` is `${PWD}`, `/` and nothing); +// (`${PWD:0:1}` is `${PWD}`, `/` and nothing); a positional or special +// parameter's slice or part (`${@:2}`, `${1:2}`) is what the parameters +// hold, as `$2` is; // - a case change (`${HOME^^}`, `${HOME@U}`), which names the same directory // on a case-insensitive disk, and `@E` and `@P`, which change no path, // each also nothing, for a value it maps to nothing (`${X^}/` is `/`); @@ -494,6 +496,11 @@ func spellParameterAt(body string, depth int, split bool) []string { } var texts []string switch { + case rest[0] == ':' && !indirect && !isNameStart(name[0]): + // A positional or special parameter's slice (`${@:2}`, `${*:2}`) + // or part (`${1:2}`) prints what the parameters hold, or nothing, + // as `"$2"` does (reverify-guardSet finding 3). + texts = append([]string(nil), value...) case rest[0] == ':': // A substring: a part of the value, the whole of it at offset 0, the // `/` an absolute path begins with, and nothing at an offset past @@ -1091,6 +1098,12 @@ func spellWord(w string, depth int, split bool) []string { return spellWritten(word, sites, nil) } +// isNameStart reports whether c can begin a shell variable's name, as a +// positional parameter's digit and a special parameter's byte cannot. +func isNameStart(c byte) bool { + return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' +} + // isNameByte reports whether c can continue a shell variable's name. func isNameByte(c byte) bool { return c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' From 282908797edfc7402e5fee6f7285e07d9d83e70e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:54:36 +0100 Subject: [PATCH 23/69] chore: resolve reverify-guardSet's four finding classes The IFS named through an expansion (3c68b005c), the empty value kept under a colon default (8b6ddeb18), the expansion that prints nothing beside the root (97f1d7a31) and the positional slice read as a substring (9e5fb2e3c), each with impact fix. Resolves: iss-2609300651115290 Resolves: iss-2609300651127327 Resolves: iss-2609300651133651 Resolves: iss-2609300651122268 Assisted-by: Claude:claude-opus-5-5 --- ...root-or-home-is-bypassed-by-an-ifs-named-through-an.md | 8 ++++++++ ...ot-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md | 8 ++++++++ ...ot-or-home-over-blocks-an-everyday-clean-line-rm-rf.md | 8 ++++++++ ...f-root-or-home-allows-expansions-that-print-nothing.md | 8 ++++++++ 4 files changed, 32 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609300651115290-rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an.md (58%) rename .abcd/work/issues/{open => resolved}/iss-2609300651122268-rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md (67%) rename .abcd/work/issues/{open => resolved}/iss-2609300651127327-rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf.md (62%) rename .abcd/work/issues/{open => resolved}/iss-2609300651133651-rm-rf-root-or-home-allows-expansions-that-print-nothing.md (63%) diff --git a/.abcd/work/issues/open/iss-2609300651115290-rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an.md b/.abcd/work/issues/resolved/iss-2609300651115290-rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an.md similarity index 58% rename from .abcd/work/issues/open/iss-2609300651115290-rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an.md rename to .abcd/work/issues/resolved/iss-2609300651115290-rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an.md index 24a11b4a1..60b5af57e 100644 --- a/.abcd/work/issues/open/iss-2609300651115290-rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an.md +++ b/.abcd/work/issues/resolved/iss-2609300651115290-rm-rf-root-or-home-is-bypassed-by-an-ifs-named-through-an.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/payload.go" remedy: "Count as naming IFS any declaration, read, mapfile, getopts or let word, any printf -v or wait -p target, and any assignment-shaped word whose name holds an expansion's mark, plus an arithmetic expression that names IFS or assigns through an expansion; fail closed. Grounds: bash 3.2, /bin/sh, dash and bash 5.3 split ${U:-x/x} into an empty field and / after each form." +resolution: "namesIFS counts a declaration, read, mapfile, getopts or let word, a printf -v or wait -p target, and an assignment-shaped word whose assigned name holds an expansion's mark, and the tokenizer raises segment.arithmeticAssigns for an arithmetic body that names IFS or assigns through an expansion; TestIFSNamedThroughAMarkTheWrittenCompareReads." +impact: fix +resolved_by: + commit: "3c68b005c" --- rm-rf-root-or-home is bypassed by an IFS named through an expansion: with I=I, `export ${I}FS=x; rm -rf ${U:-x/x}` allows and hands rm "" and / on bash 3.2, /bin/sh, dash and bash 5.3, and so do `eval "I${F:-F}S=x"`, `declare|typeset|readonly|local ${I}FS=x`, `read -r ${I}FS`, `printf -v ${I}FS x` and `: $((IFS=1))`. namesIFS matched the literal text IFS only, and the tokenizer steps over arithmetic. + +## Grounds + +- pursued: every form the review named, and the arithmetic, let, getopts, mapfile and wait -p siblings, now blocks while IFS= read -r f and IFS=, read -ra arr with a quoted operand stay allowed; a name built some way the guard still does not read (a sourced file, a nameref set before the line) would show it wrong, and 17-guard.md names those as residuals. diff --git a/.abcd/work/issues/open/iss-2609300651122268-rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md b/.abcd/work/issues/resolved/iss-2609300651122268-rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md similarity index 67% rename from .abcd/work/issues/open/iss-2609300651122268-rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md rename to .abcd/work/issues/resolved/iss-2609300651122268-rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md index cec9b58de..e8e3fc2c4 100644 --- a/.abcd/work/issues/open/iss-2609300651122268-rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md +++ b/.abcd/work/issues/resolved/iss-2609300651122268-rm-rf-root-or-home-over-blocks-a-positional-slice-rm-rf-2-1.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/unknown.go" remedy: "Read a positional or special parameter's slice or part (${@:2}, ${*:2}, ${1:2}) as the parameters it prints, as \"$2\" is read, rather than as a variable's substring that can be the root. Grounds: bash 3.2, /bin/sh and bash 5.3 print the later arguments for \"${@:2}\"; dash has no slice." +resolution: "spellParameterAt reads a positional or special parameter's slice or part as the parameters' value and nothing, as \"$2\" is read; TestPositionalSlicesReadAsTheParameters." +impact: fix +resolved_by: + commit: "9e5fb2e3c" --- rm-rf-root-or-home over-blocks a positional slice: `rm -rf "${@:2}"`, `"${@:1}"`, `${@:2}`, `"${*:2}"` and `"${1:2}"` block, though each prints what the parameters hold, as `rm -rf "$2"` (allowed) does. The substring reading, which adds the root, reached positional and special parameters. + +## Grounds + +- pursued: "${@:2}", "${@:1}", ${@:2}, "${*:2}" and "${1:2}" allow as "$2" does, and "${@:2}"/ blocks as "$@"/ does; a slice that prints the root where the parameters do not would show it wrong. diff --git a/.abcd/work/issues/open/iss-2609300651127327-rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf.md b/.abcd/work/issues/resolved/iss-2609300651127327-rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf.md similarity index 62% rename from .abcd/work/issues/open/iss-2609300651127327-rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf.md rename to .abcd/work/issues/resolved/iss-2609300651127327-rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf.md index e474e0aaa..654cb9652 100644 --- a/.abcd/work/issues/open/iss-2609300651127327-rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf.md +++ b/.abcd/work/issues/resolved/iss-2609300651127327-rm-rf-root-or-home-over-blocks-an-everyday-clean-line-rm-rf.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/unknown.go" remedy: "Read a colon default, assignment or error message (${1:-w}, ${1:=w}, ${1:?}) without the empty text an emptyable parameter adds, and keep it under the colonless forms, where a set but empty parameter prints its empty value. Grounds: bash 3.2, /bin/sh, dash and bash 5.3 print dist/ for ${1:-dist}/ with no argument or an empty one." +resolution: "spellParameterAt reads the colon default, assignment and error message from the value without the empty text an emptyable parameter adds, and keeps it under the colonless forms; TestColonDefaultsOfAnEmptyParameterTheWrittenCompareReads." +impact: fix +resolved_by: + commit: "8b6ddeb18" --- rm-rf-root-or-home over-blocks an everyday clean line: `rm -rf "${1:-build}"/*`, `rm -rf ${1:-dist}/`, `rm -rf "${1:-dist}/"*` and `rm -rf ${@:-x}/` block, and `rm -rf ./${1:-dist}` warns, though no shell prints the empty value under `:-`. The empty text an emptyable parameter can print was kept under the colon operators. + +## Grounds + +- pursued: rm -rf "${1:-build}"/*, ${1:-dist}/, "${1:-dist}/"* and ${@:-x}/ allow again and ./${1:-dist} no longer warns, while ${1:-/}, ${1-}/ and ${1-dist}/ block; a shell that prints the empty value under :- would show it wrong, and bash 3.2, /bin/sh, dash and bash 5.3 do not. diff --git a/.abcd/work/issues/open/iss-2609300651133651-rm-rf-root-or-home-allows-expansions-that-print-nothing.md b/.abcd/work/issues/resolved/iss-2609300651133651-rm-rf-root-or-home-allows-expansions-that-print-nothing.md similarity index 63% rename from .abcd/work/issues/open/iss-2609300651133651-rm-rf-root-or-home-allows-expansions-that-print-nothing.md rename to .abcd/work/issues/resolved/iss-2609300651133651-rm-rf-root-or-home-allows-expansions-that-print-nothing.md index c1d074710..9c7a416f3 100644 --- a/.abcd/work/issues/open/iss-2609300651133651-rm-rf-root-or-home-allows-expansions-that-print-nothing.md +++ b/.abcd/work/issues/resolved/iss-2609300651133651-rm-rf-root-or-home-allows-expansions-that-print-nothing.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/unknown.go" remedy: "Read an empty default word as the empty text, and a subscript alone, a case change and an @ transform as the value or nothing; drop the empty text wherever ifsSplits reads a site, since it splits into no field. Grounds: bash 3.2, /bin/sh and bash 5.3 print / for ${X:-}/, ${X-}/, ${A[0]}/ and ${A[@]}/ with X and A unset, and bash 5.3 for ${X^}/ and ${X@P}/ with X empty." +resolution: "spellWord reads an empty word as the empty text, a subscript alone, a case change and an @ transform read as the value or nothing, and ifsSplits drops the empty text of every site; TestExpansionsThatCanPrintNothingTheWrittenCompareReads." +impact: fix +resolved_by: + commit: "97f1d7a31" --- rm-rf-root-or-home allows expansions that print nothing beside a root: `rm -rf ${X:-}/`, `${X-}/`, `$HOME${X:-}`, `${A[0]}/`, `${A[@]}/`, and bash 5 `${X^}/` and `${X@P}/` hand rm / or the home, while `${1:-}/` blocks. spellWord read an empty word as no text, and the subscript, case and @ operators returned the value alone. + +## Grounds + +- pursued: ${X:-}/, ${X-}/, $HOME${X:-}, ${A[0]}/, ${A[@]}/, ${X^}/ and ${X@P}/ block, while "${files[@]}", "${TMPDIR:-}/abcd-x" and an IFS-named line with ${f:-} stay allowed; the documented residual $X/ (a variable's own empty value) still allows, and 17-guard.md says why. From 788f0a55073f7b925b6fec4125679f002b465391 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:12:26 +0100 Subject: [PATCH 24/69] fix(drain): refuse a rule record stating any key twice, cap its read, hand back deferrals when no tag is known Three findings of the drainOwnRule review, on the path that decides what an unattended drain may take. The rule's reader narrowed frontmatter.Duplicates to drain_ keys, so a record stating `status: accepted` then `status: superseded` loaded on the line scanner's first-wins reading while every YAML reader calls it superseded. Any candidate record (one carrying a drain_ key) that states any top-level key twice now refuses as malformed, whatever its status reads as. A record whose frontmatter id disagrees with the id its file name gives it refuses too, since every surface names the rule by it. Each record is read through fsutil.ReadGuardedInRoot under issueschema.RecordReadLimit, so a record that is a link (even one resolving inside the checkout) or past the cap refuses under a new sentinel, drainrule.ErrUnreadable, and `drain --dry-run` exits 2 on every refusal of the rule as the bare verb does. With no release tag in the checkout (a --depth 1 --no-tags clone, the unattended drain's likely checkout), every deferred_after read as lapsed. The anchor is now marked unknown and every record carrying a deferral is handed back as `deferred`, the reason and the dry run naming the missing tags and `git fetch --tags`; a failed tag read still refuses. Handing back was chosen over refusing the plan: the rest of the dry run stays readable, and nothing carrying a deferral is taken. "Waits on" is matched as words: followed by a blank, a colon or the end, so "Waits on: ruling H4." is handed back and "Waits onward" is not. Sweep: the issue records the drain reads go through issuerecord's capped reader and its parser refuses a duplicate key (the record lands as unreadable), so the drain's other repository-authored input already holds both properties. Refs: iss-2609300711394709, iss-2609300711402515 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/35-drain.md | 27 +++-- ...live-deferral-through-when-the-checkout.md | 15 +++ ...y-reader-admits-a-candidate-record-that.md | 15 +++ commands/drain.md | 17 ++- internal/core/capture/eligible.go | 82 +++++++++----- internal/core/capture/eligible_test.go | 80 ++++++++++++- internal/core/drainrule/drainrule.go | 82 ++++++++++---- internal/core/drainrule/drainrule_test.go | 105 +++++++++++++++++- internal/surface/cli/drain.go | 19 +++- internal/surface/cli/drain_surface_test.go | 61 ++++++++++ 10 files changed, 429 insertions(+), 74 deletions(-) create mode 100644 .abcd/work/issues/open/iss-2609300711394709-abcd-drain-lets-a-live-deferral-through-when-the-checkout.md create mode 100644 .abcd/work/issues/open/iss-2609300711402515-the-drain-eligibility-reader-admits-a-candidate-record-that.md diff --git a/.abcd/development/brief/04-surfaces/35-drain.md b/.abcd/development/brief/04-surfaces/35-drain.md index 12443f13a..31104ab73 100644 --- a/.abcd/development/brief/04-surfaces/35-drain.md +++ b/.abcd/development/brief/04-surfaces/35-drain.md @@ -71,8 +71,9 @@ a person writes one; and unreadable when the ledger reader refuses the record. A record written before the field existed reads its `suggested_fix:` as its remedy. Two hand-backs hold whatever the repository's record says, because each marks a -decision a person still owes. A remedy that opens "Waits on" (the shape a remedy -takes when its fix waits on an unanswered ruling, compared case-folded) is +decision a person still owes. A remedy that opens "Waits on" as words, followed +by a blank, a colon or nothing (the shape a remedy takes when its fix waits on an +unanswered ruling, compared case-folded), is handed back under `waits-on-ruling`: taking it would make the ruling. A record whose `deferred_after` names the checkout's current anchor tag, the newest release tag, is handed back under `deferred`: a person carried it past this @@ -81,8 +82,11 @@ record carrying both is named for the ruling, which says which decision is owed. The deferral verb writes a deferral only onto a `major` or `critical` record, but a hand-written one on a lighter record is read the same way. The release tags are -read only when an open record carries a deferral, and a failure to read them -refuses the dry run rather than letting a live deferral through. +read only when an open record carries a deferral, and not knowing whether a +deferral is live never lets its record through: a failure to read the tags +refuses the dry run, and a checkout holding no release tag (a shallow clone +fetches none) marks the anchor unknown and hands back every record carrying a +deferral, the dry run naming the missing tags and `git fetch --tags`. The fields are the rule because a model's judgement of its own ambiguity is unreliable, and the failure runs one way: a machine that decides a thing needs @@ -125,8 +129,15 @@ so that a later drain takes issues a person would have decided. What guards it: - abcd's own repository keeps the strict baseline, and a test fails when its record loosens anything or stops being the record the invariant cites. - The reader never falls back: a missing, partial, ambiguous or malformed record - refuses, and the store is read inside the checkout, so a store or record that - is a symlink leaving it is refused rather than followed. + refuses. A record stating any frontmatter key twice is malformed, because the + line scanner keeps the first value and a YAML reader the last, so + `status: accepted` then `status: superseded` would read as two decisions; so + is a record whose frontmatter `id` disagrees with the id its file name gives + it, which would put another record's name on its rule. +- The store is read inside the checkout and each record through the capped + trust-boundary reader, so a store that is a symlink leaving the checkout, a + record that is a symlink at all, and a record past the ledger's size cap are + refused rather than followed or read whole. - The two person-owed hand-backs, a remedy waiting on a ruling and a live deferral, hold whatever the record says. @@ -136,8 +147,8 @@ The dry run and the bare verb both refuse, exit 2 with nothing written, when the repository holds no accepted record of the rule, naming how to add one (the setup verb's offer, or the four fields on an accepted record); when a record names the fields but is proposed or superseded, the refusal names it. They -refuse a malformed record, naming the record and the field, and two accepted -records, naming both. With the rule, the bare verb still refuses to start: the +refuse a malformed record, naming the record and the field, two accepted +records, naming both, and a store or record that cannot be read safely. With the rule, the bare verb still refuses to start: the lane it would hand each issue to does not exist, and the refusal names the rule's record and every floor it loosens. A checkout that cannot be resolved, or a ledger holding one id in two status folders, is refused as every capture diff --git a/.abcd/work/issues/open/iss-2609300711394709-abcd-drain-lets-a-live-deferral-through-when-the-checkout.md b/.abcd/work/issues/open/iss-2609300711394709-abcd-drain-lets-a-live-deferral-through-when-the-checkout.md new file mode 100644 index 000000000..40e97cd21 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300711394709-abcd-drain-lets-a-live-deferral-through-when-the-checkout.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300711394709" +slug: "abcd-drain-lets-a-live-deferral-through-when-the-checkout" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/capture/eligible.go" +remedy: "When an open record carries a deferral and no release tag is found, mark the anchor unknown and hand back every record carrying a deferral under the deferred rule, naming the missing tags and git fetch --tags, with the dry run saying the anchor is unknown; keep the refusal on a failed tag read; test on a --depth 1 --no-tags clone." +--- + +abcd drain lets a live deferral through when the checkout holds no release tag: liveDeferralAnchor returns an empty anchor when no tag is found, so every deferred_after reads as lapsed and a record a person carried past this release is eligible. A shallow or tagless clone (fetch-depth 1 fetches no tags) is the unattended drain's likely checkout, and the comment above the function promised the opposite. diff --git a/.abcd/work/issues/open/iss-2609300711402515-the-drain-eligibility-reader-admits-a-candidate-record-that.md b/.abcd/work/issues/open/iss-2609300711402515-the-drain-eligibility-reader-admits-a-candidate-record-that.md new file mode 100644 index 000000000..df9c028e3 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300711402515-the-drain-eligibility-reader-admits-a-candidate-record-that.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300711402515" +slug: "the-drain-eligibility-reader-admits-a-candidate-record-that" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/drainrule/drainrule.go" +remedy: "Refuse, as malformed, any candidate record that states any top-level key twice (Duplicates already returns them) whatever its status reads as, and one whose frontmatter id disagrees with the id its file name gives it; read each record through fsutil.ReadGuardedInRoot with issueschema.RecordReadLimit so a link or an oversized record refuses; map every refusal of the rule load to exit 2 on the dry run." +--- + +The drain eligibility reader admits a candidate record that states a non-drain key twice: drainrule.Load narrowed frontmatter.Duplicates to drain_ keys, so status: accepted followed by status: superseded loads and applies on the line scanner's first-wins reading, while any YAML reader (last wins) calls the record superseded. The same reader read every store record uncapped (root.ReadFile) and trusted a frontmatter id that disagrees with the file name. diff --git a/commands/drain.md b/commands/drain.md index 0187b6b6f..463ece1a1 100644 --- a/commands/drain.md +++ b/commands/drain.md @@ -47,7 +47,8 @@ alone: record carrying only the older `suggested_fix:` reads that as its remedy), the remedy is not `none (filed automatically)`, the value abcd's automatic filers write when they have no fix, and it does not open "Waits on"; -- it carries no deferral that is live at the checkout's newest release tag. +- it carries no deferral that is live at the checkout's newest release tag, and + none at all when the checkout holds no release tag. The rules are asked in a fixed order, and the first that excludes an issue decides its one disposition: @@ -59,7 +60,7 @@ decides its one disposition: | `handback` | `category` | a category the rule does not take | | `handback` | `severity` | a severity the rule does not take (`major` and `critical` under the baseline) | | `handback` | `waits-on-ruling` | its remedy opens "Waits on": the fix waits on a ruling a person has not given | -| `handback` | `deferred` | its `deferred_after` names the current anchor tag: a person carried it past this release | +| `handback` | `deferred` | its `deferred_after` names the current anchor tag: a person carried it past this release; or the checkout holds no release tag (a shallow clone fetches none), so whether any deferral is live is unknown and the reason names `git fetch --tags` | | `ineligible` | `remedy` | no remedy (a record filed before the remedy was required), or `none (filed automatically)` from an automatic filer; ineligible until a person writes one with `abcd capture remedy`, which the reason names | | `unreadable` | `unreadable` | the ledger reader refuses the record; the reason names why | | `eligible` | `fields` | every field rule passes | @@ -92,7 +93,8 @@ a person should have reviewed. read from; `rule` is that record's rule (`record`, `path`, `categories`, `severities`, `security`, `remedy`, `loosened`); `loosened` lists every floor it loosens (empty when none); `anchor` is the release tag a live deferral names, -present when an open record carries a deferral; `order` is the ordering rule; +present when an open record carries a deferral; `anchor_unknown` is `true` when +an open record carries a deferral and the checkout holds no release tag; `order` is the ordering rule; `dispositions` holds one entry per open issue (`id`, `path`, `severity`, `category`, `outcome`, `rule`, `reason`, and `blockers` when skipped); `counts` totals them by outcome; `ledger` names the checkout and branch read. @@ -113,9 +115,12 @@ on. Do not act on the list: a hand-back is a person's decision. decision record the four `drain_` fields. A record carrying the fields but proposed or superseded is named. Relay this; do not write the record for the user. -- A malformed record (a field missing, misspelt, stated twice, or holding a - value the field does not take) refuses, naming the record and the field; two - accepted records carrying the fields refuse, naming both. +- A malformed record (a field missing or misspelt, any frontmatter key stated + twice, an `id` its file name does not give it, or a value the field does not + take) refuses, naming the record and the field; two accepted records carrying + the fields refuse, naming both. A decision store or record that cannot be read + safely (a symlink, or a record past the size cap) refuses. Every one of these + exits 2 with nothing written, on the dry run and the bare verb alike. - Without `--dry-run` the verb refuses to start (exit 2, nothing written): the issue-keyed lane a drain hands each issue to is not built. The refusal names the rule's record, every floor it loosens, and the dry run. diff --git a/internal/core/capture/eligible.go b/internal/core/capture/eligible.go index 0386d2c61..ea1ebdb83 100644 --- a/internal/core/capture/eligible.go +++ b/internal/core/capture/eligible.go @@ -83,8 +83,7 @@ type DrainVerdict struct { } // eligibility judges one issue by its fields alone, under the repository's -// rule r, with anchor the checkout's current release tag ("" when it has -// none). iss.BlockedByOpen must be the derived projection List fills (the +// rule r, with anchor the checkout's current release tag as far as it is known. iss.BlockedByOpen must be the derived projection List fills (the // blockers still in open/). The rules are asked in a fixed order, and the // first that excludes the issue decides: not open, blocked, security, a // category outside the rule's set, a severity outside it, a remedy that waits @@ -99,7 +98,7 @@ type DrainVerdict struct { // because it says which decision is owed; a record carrying both waits on // both. `capture defer` writes a deferral only onto a major or // critical record, but a hand-written one on a lighter record is read alike. -func eligibility(iss Issue, r drainrule.Rule, anchor string) DrainVerdict { +func eligibility(iss Issue, r drainrule.Rule, anchor deferralAnchor) DrainVerdict { v := DrainVerdict{ID: iss.ID, Path: iss.Path, Severity: iss.Severity, Category: iss.Category} decide := func(o DrainOutcome, rule DrainRule, reason string) DrainVerdict { v.Outcome, v.Rule, v.Reason = o, rule, reason @@ -122,9 +121,13 @@ func eligibility(iss Issue, r drainrule.Rule, anchor string) DrainVerdict { case waitsOnRuling(iss.Remedy): return decide(DrainHandBack, RuleWaitsOnRuling, "the remedy opens \"Waits on\": the fix waits on a person's ruling, so it is a person's until the ruling is given and the remedy rewritten") - case anchor != "" && iss.deferredAfter == anchor: + case anchor.unknown && iss.deferredAfter != "": return decide(DrainHandBack, RuleDeferred, fmt.Sprintf( - "deferred past %s, the current anchor: a person carried it past this release, so it is a person's until the deferral lapses", anchor)) + "deferred past %s, anchor unknown: this checkout holds no release tag (a shallow clone fetches none), so whether the deferral is live cannot be read and it is a person's; `git fetch --tags` and drain again", + iss.deferredAfter)) + case anchor.tag != "" && iss.deferredAfter == anchor.tag: + return decide(DrainHandBack, RuleDeferred, fmt.Sprintf( + "deferred past %s, the current anchor: a person carried it past this release, so it is a person's until the deferral lapses", anchor.tag)) case strings.TrimSpace(iss.Remedy) == "": return decide(DrainIneligible, RuleRemedy, fmt.Sprintf( "no remedy: field; ineligible until someone adds one with `abcd capture remedy %s \"<fix>\"`", iss.ID)) @@ -141,12 +144,18 @@ func eligibility(iss Issue, r drainrule.Rule, anchor string) DrainVerdict { // waitsOnPrefix opens a remedy whose fix waits on an unanswered ruling, the // shape the ledger's remedies are written in ("Waits on <ruling>: ..."). -const waitsOnPrefix = "waits on " +const waitsOnPrefix = "waits on" -// waitsOnRuling reports whether a remedy opens "Waits on", compared -// case-folded after leading blanks, so a lower-case spelling is held back too. +// waitsOnRuling reports whether a remedy opens "Waits on" as words: followed +// by a blank, a colon, or nothing, compared case-folded after leading blanks, +// so "Waits on: ruling H4." and a lower-case spelling are held back too, and +// "Waits onward" is not. func waitsOnRuling(remedy string) bool { - return strings.HasPrefix(strings.ToLower(strings.TrimSpace(remedy)), waitsOnPrefix) + rest, ok := strings.CutPrefix(strings.ToLower(strings.TrimSpace(remedy)), waitsOnPrefix) + if !ok { + return false + } + return rest == "" || rest[0] == ':' || rest[0] == ' ' || rest[0] == '\t' || rest[0] == '\n' || rest[0] == '\r' } // drainOrder states the ordering rule r takes eligible issues in. @@ -191,10 +200,14 @@ type DrainPlan struct { Loosened []string `json:"loosened"` // Anchor is the release tag a live deferral names, when any open record // carries a deferral; empty otherwise. - Anchor string `json:"anchor,omitempty"` - Order string `json:"order"` - Dispositions []DrainVerdict `json:"dispositions"` - Counts map[DrainOutcome]int `json:"counts"` + Anchor string `json:"anchor,omitempty"` + // AnchorUnknown reports that an open record carries a deferral and the + // checkout holds no release tag (a shallow clone fetches none), so every + // record carrying a deferral is handed back rather than judged. + AnchorUnknown bool `json:"anchor_unknown,omitempty"` + Order string `json:"order"` + Dispositions []DrainVerdict `json:"dispositions"` + Counts map[DrainOutcome]int `json:"counts"` } // PlanDrain classifies every open issue by field, under the repository's own @@ -241,13 +254,14 @@ func PlanDrain(req DrainPlanRequest) (DrainPlan, error) { orderEligible(eligible, rule) sort.SliceStable(rest, func(i, j int) bool { return issNumber(rest[i].ID) < issNumber(rest[j].ID) }) plan := DrainPlan{ - Record: rule.Record, - Rule: rule, - Loosened: rule.Loosened, - Anchor: anchor, - Order: drainOrder(rule), - Dispositions: append(append([]DrainVerdict{}, eligible...), rest...), - Counts: map[DrainOutcome]int{}, + Record: rule.Record, + Rule: rule, + Loosened: rule.Loosened, + Anchor: anchor.tag, + AnchorUnknown: anchor.unknown, + Order: drainOrder(rule), + Dispositions: append(append([]DrainVerdict{}, eligible...), rest...), + Counts: map[DrainOutcome]int{}, } for _, v := range plan.Dispositions { plan.Counts[v.Outcome]++ @@ -255,23 +269,33 @@ func PlanDrain(req DrainPlanRequest) (DrainPlan, error) { return plan, nil } +// deferralAnchor is the release tag a deferral is judged against: the tag, or +// unknown when an open record carries a deferral and the checkout holds no +// release tag. The zero value is "no deferral to judge". +type deferralAnchor struct { + tag string + unknown bool +} + // liveDeferralAnchor returns the checkout's current release tag when any open -// record carries a deferral, and "" when none does or the checkout has no -// release tag. The tags are read only when a deferral needs judging, and a -// failure to read them refuses the plan: a live deferral is a person's -// decision, and not knowing whether it is live must not let the record through. -func liveDeferralAnchor(repoRoot string, issues []Issue) (string, error) { +// record carries a deferral, and the zero anchor when none does. The tags are +// read only when a deferral needs judging, and not knowing whether a deferral +// is live never lets its record through: a failure to read the tags refuses +// the plan, and a checkout holding no release tag (a shallow clone fetches +// none) marks the anchor unknown, which hands back every record carrying a +// deferral. A live deferral is a person's decision. +func liveDeferralAnchor(repoRoot string, issues []Issue) (deferralAnchor, error) { if !slices.ContainsFunc(issues, func(iss Issue) bool { return iss.deferredAfter != "" }) { - return "", nil + return deferralAnchor{}, nil } tag, found, err := changelog.LatestReleaseTag(repoRoot) if err != nil { - return "", fmt.Errorf("drain: an open record carries a deferral, and the release tags that say whether it is live could not be read: %w", err) + return deferralAnchor{}, fmt.Errorf("drain: an open record carries a deferral, and the release tags that say whether it is live could not be read: %w", err) } if !found { - return "", nil + return deferralAnchor{unknown: true}, nil } - return tag.Tag(), nil + return deferralAnchor{tag: tag.Tag()}, nil } // ErrDrainRuleUnrecorded is the refusal when the drained repository holds no diff --git a/internal/core/capture/eligible_test.go b/internal/core/capture/eligible_test.go index 844560835..59da1b5a5 100644 --- a/internal/core/capture/eligible_test.go +++ b/internal/core/capture/eligible_test.go @@ -187,30 +187,30 @@ func TestDrainRoutesAMixedLedgerByField(t *testing.T) { func TestEligibilityIsTheFieldRuleAndNothingElse(t *testing.T) { for _, cat := range []Category{"tech-debt", "documentation", "inconsistency", "drift", "bug", "ux"} { for _, sev := range []Severity{SeverityNitpick, SeverityMinor} { - v := eligibility(Issue{ID: "iss-1", Category: cat, Severity: sev, Remedy: "r", Status: StateOpen}, drainrule.Baseline(), "") + v := eligibility(Issue{ID: "iss-1", Category: cat, Severity: sev, Remedy: "r", Status: StateOpen}, drainrule.Baseline(), deferralAnchor{}) if v.Outcome != DrainEligible { t.Errorf("%s/%s with a remedy: %s (%s), want eligible", cat, sev, v.Outcome, v.Reason) } } } for _, cat := range []Category{"process", "observation", "architectural-insight", "future-work-seed", "lapse"} { - v := eligibility(Issue{ID: "iss-1", Category: cat, Severity: SeverityMinor, Remedy: "r", Status: StateOpen}, drainrule.Baseline(), "") + v := eligibility(Issue{ID: "iss-1", Category: cat, Severity: SeverityMinor, Remedy: "r", Status: StateOpen}, drainrule.Baseline(), deferralAnchor{}) if v.Outcome != DrainHandBack || v.Rule != RuleCategory { t.Errorf("%s: %s/%s, want handback/category", cat, v.Outcome, v.Rule) } } for _, sev := range []Severity{SeverityMajor, SeverityCritical} { - v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: sev, Remedy: "r", Status: StateOpen}, drainrule.Baseline(), "") + v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: sev, Remedy: "r", Status: StateOpen}, drainrule.Baseline(), deferralAnchor{}) if v.Outcome != DrainHandBack || v.Rule != RuleSeverity { t.Errorf("%s: %s/%s, want handback/severity", sev, v.Outcome, v.Rule) } } // A remedy of blanks is no remedy. - if v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: SeverityMinor, Remedy: " \t", Status: StateOpen}, drainrule.Baseline(), ""); v.Outcome != DrainIneligible { + if v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: SeverityMinor, Remedy: " \t", Status: StateOpen}, drainrule.Baseline(), deferralAnchor{}); v.Outcome != DrainIneligible { t.Errorf("a blank remedy: %s, want ineligible", v.Outcome) } // A record that is not open is never a drain's to take. - if v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: SeverityMinor, Remedy: "r", Status: StateResolved}, drainrule.Baseline(), ""); v.Outcome == DrainEligible { + if v := eligibility(Issue{ID: "iss-1", Category: "bug", Severity: SeverityMinor, Remedy: "r", Status: StateResolved}, drainrule.Baseline(), deferralAnchor{}); v.Outcome == DrainEligible { t.Errorf("a resolved record was eligible") } } @@ -531,3 +531,73 @@ func TestAbcdsOwnDrainRuleIsTheStrictBaseline(t *testing.T) { t.Errorf("the brief's invariants do not cite %s", abcdsOwnRuleRecord) } } + +// TestADeferralIsHandedBackWhenTheCheckoutHoldsNoReleaseTag: a shallow clone +// (fetch-depth 1, the unattended drain's likely checkout) fetches no tags, so +// whether a deferral is live cannot be known. Not knowing must not let the +// record through: every record carrying a deferral is handed back, naming the +// missing tags and how to fetch them, and a record without one is untouched. +func TestADeferralIsHandedBackWhenTheCheckoutHoldsNoReleaseTag(t *testing.T) { + src := gittest.NewRepo(t) + src.Commit("root") + src.Git("tag", "v0.1.0") + repo := src.Root() + ir := filepath.Join(repo, LedgerRelPath) + writeRuleRecord(t, repo, loosenedFields) + f := drainFixture{t: t, repo: repo, ir: ir} + f.file("iss-2", SeverityMajor, "bug", "rewrite the parser") + f.file("iss-3", SeverityMajor, "bug", "rewrite the lexer") + f.file("iss-5", SeverityMinor, "bug", "guard the nil map") + setDeferral(t, ir, "iss-2", "v0.1.0") + setDeferral(t, ir, "iss-3", "v0.0.9") + src.Commit("ledger") + src.Git("tag", "v0.2.0") + + clone := filepath.Join(t.TempDir(), "clone") + src.Git("clone", "--quiet", "--depth", "1", "--no-tags", "file://"+repo, clone) + shallow := drainFixture{t: t, repo: clone, ir: filepath.Join(clone, LedgerRelPath)} + p := shallow.plan() + if p.Anchor != "" { + t.Errorf("a tagless clone reports anchor %q", p.Anchor) + } + cases := map[string]string{ + "iss-2": "handback/deferred", + "iss-3": "handback/deferred", + "iss-5": "eligible/fields", + } + for id, want := range cases { + v := verdictOf(t, p, id) + if got := string(v.Outcome) + "/" + string(v.Rule); got != want { + t.Errorf("%s: %s (%s), want %s", id, got, v.Reason, want) + } + if want == "handback/deferred" { + for _, w := range []string{"anchor unknown", "no release tag", "git fetch --tags"} { + if !strings.Contains(v.Reason, w) { + t.Errorf("%s: the reason %q does not name %q", id, v.Reason, w) + } + } + } + } + if !p.AnchorUnknown { + t.Error("the plan does not say its anchor is unknown") + } +} + +// TestWaitsOnIsReadAsAWordNotAPrefix: "Waits on" followed by a colon, or +// ending the remedy, opens a remedy that waits on a ruling as surely as one +// followed by a space; a word that merely starts with it does not. +func TestWaitsOnIsReadAsAWordNotAPrefix(t *testing.T) { + for remedy, want := range map[string]bool{ + "Waits on: ruling H4.": true, + "WAITS ON:H4": true, + "waits on": true, + " Waits on\tH4: then do it": true, + "Waits on ruling G: drop it": true, + "Waits onward: nothing": false, + "guard the map; it waits on none": false, + } { + if got := waitsOnRuling(remedy); got != want { + t.Errorf("waitsOnRuling(%q) = %v, want %v", remedy, got, want) + } + } +} diff --git a/internal/core/drainrule/drainrule.go b/internal/core/drainrule/drainrule.go index 52227cb14..ef5126147 100644 --- a/internal/core/drainrule/drainrule.go +++ b/internal/core/drainrule/drainrule.go @@ -44,6 +44,7 @@ import ( "github.com/intentdriven/abcd/internal/core/frontmatter" "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/recordid" + "github.com/intentdriven/abcd/internal/fsutil" ) // ADRsRelDir is the decision store the rule is read from, repo-relative and @@ -136,6 +137,9 @@ var ( ErrMalformed = errors.New("the drain eligibility record is malformed") // ErrAmbiguous: more than one accepted record states the rule. ErrAmbiguous = errors.New("more than one accepted record states the drain eligibility rule") + // ErrUnreadable: the decision store, or a record in it, could not be read + // safely: a link, a file past the size cap, or a read that failed. + ErrUnreadable = errors.New("the decision store the drain eligibility rule is read from could not be read safely") ) // HowToAdd is the remedy every ErrUnrecorded refusal names. @@ -147,16 +151,19 @@ const HowToAdd = "add it: run `abcd ahoy install` at a terminal and accept the d // Load reads the repository's drain eligibility rule from its decision store. // It refuses, with ErrUnrecorded, a repository whose store holds no accepted // record carrying the drain fields; with ErrAmbiguous, one holding two; and -// with ErrMalformed, a record that is partial or states a value the rule does -// not take. +// with ErrMalformed, a record that is partial, states a value the rule does +// not take, states any key twice, or claims an id its file name does not give +// it; and with ErrUnreadable, a store or record that cannot be read safely. // -// The store is read inside an os.Root at the checkout, so a store or record -// that is a symlink leaving the checkout is refused, never followed: the rule -// is the drained tree's committed history, and a rule from elsewhere is not it. +// The store is read inside an os.Root at the checkout, and each record through +// the capped trust-boundary reader, so a store that is a symlink leaving the +// checkout, a record that is a symlink at all, and a record past the size cap +// are refused, never followed or read whole: the rule is the drained tree's +// committed history, and a rule from elsewhere is not it. func Load(repoRoot string) (Rule, error) { root, err := os.OpenRoot(repoRoot) if err != nil { - return Rule{}, fmt.Errorf("drain rule: opening the checkout: %w", err) + return Rule{}, fmt.Errorf("%w: opening the checkout: %w", ErrUnreadable, err) } defer root.Close() entries, err := fs.ReadDir(root.FS(), ADRsRelDir) @@ -164,39 +171,55 @@ func Load(repoRoot string) (Rule, error) { return Rule{}, fmt.Errorf("%w: it has no decision store at %s; %s", ErrUnrecorded, ADRsRelDir, HowToAdd) } if err != nil { - return Rule{}, fmt.Errorf("drain rule: reading %s: %w", ADRsRelDir, err) + return Rule{}, fmt.Errorf("%w: reading %s: %w", ErrUnreadable, ADRsRelDir, err) } type candidate struct { id, rel, status string fields map[string]frontmatter.Field - dups []string } var accepted, other []candidate for _, e := range entries { - if e.IsDir() || recordid.ADRFileID(e.Name()) == "" { + fileID := recordid.ADRFileID(e.Name()) + if e.IsDir() || fileID == "" { continue } rel := path.Join(ADRsRelDir, e.Name()) - raw, err := root.ReadFile(rel) + raw, err := readRecord(root, rel) if err != nil { - return Rule{}, fmt.Errorf("drain rule: reading %s: %w", rel, err) + return Rule{}, err } lines := strings.Split(string(raw), "\n") fields := frontmatter.Fields(lines) if !carriesDrainFields(fields) { continue } - c := candidate{rel: rel, fields: fields} - c.id, _ = frontmatter.ScalarString(fields["id"].Value) - if c.id == "" { - c.id = recordid.ADRFileID(e.Name()) + c := candidate{id: fileID, rel: rel, fields: fields} + // A record that states any top-level key twice says two things: the + // line scanner keeps the first value and a YAML reader the last, so + // `status: accepted` then `status: superseded` would be admitted here + // and called superseded everywhere else. Refused whatever its status + // reads as, so neither reading decides what an unattended drain takes. + if dups := frontmatter.Duplicates(lines); len(dups) > 0 { + keys := make([]string, 0, len(dups)) + for _, d := range dups { + if !slices.Contains(keys, d.Key) { + keys = append(keys, d.Key) + } + } + return Rule{}, fmt.Errorf("%w: %s (%s) states %s more than once; state each key once", + ErrMalformed, fileID, rel, strings.Join(keys, ", ")) } - c.status, _ = frontmatter.ScalarString(fields["status"].Value) - for _, d := range frontmatter.Duplicates(lines) { - if strings.HasPrefix(d.Key, fieldPrefix) { - c.dups = append(c.dups, d.Key) + // Every surface names the rule by its record's id, so a record whose + // frontmatter claims another record's id would put that record's name + // on its own rule. The file name is the id the store allocated. + if fmID, _ := frontmatter.ScalarString(fields["id"].Value); fmID != "" { + if recordid.CanonADRID(fmID) != fileID { + return Rule{}, fmt.Errorf("%w: %s says its id is %s, but its file name makes it %s; a record states its own id", + ErrMalformed, rel, fmID, fileID) } + c.id = fmID } + c.status, _ = frontmatter.ScalarString(fields["status"].Value) if c.status == "accepted" { accepted = append(accepted, c) } else { @@ -225,10 +248,6 @@ func Load(repoRoot string) (Rule, error) { ErrAmbiguous, strings.Join(ids, " and ")) } c := accepted[0] - if len(c.dups) > 0 { - return Rule{}, fmt.Errorf("%w: %s (%s) states %s more than once; state each field once", - ErrMalformed, c.id, c.rel, strings.Join(c.dups, ", ")) - } r, err := parse(c.fields) if err != nil { return Rule{}, fmt.Errorf("%w: %s (%s): %s", ErrMalformed, c.id, c.rel, err.Error()) @@ -237,6 +256,23 @@ func Load(repoRoot string) (Rule, error) { return r, nil } +// readRecord reads one record of the store through the capped trust-boundary +// reader: a link (even one resolving inside the checkout), a FIFO or device, or +// a file past the ledger's record cap is refused rather than read, since the +// store is repository-authored and decides what an unattended drain takes. +func readRecord(root *os.Root, rel string) ([]byte, error) { + raw, err := fsutil.ReadGuardedInRoot(root, rel, issueschema.RecordReadLimit) + switch { + case err == nil: + return raw, nil + case errors.Is(err, fsutil.ErrTooBig): + return nil, fmt.Errorf("%w: %s is larger than the %d-byte size cap and was left unread", ErrUnreadable, rel, issueschema.RecordReadLimit) + case errors.Is(err, fsutil.ErrNotRegular): + return nil, fmt.Errorf("%w: %s is not a regular file (a link, a FIFO or a device), and a record is never read through one", ErrUnreadable, rel) + } + return nil, fmt.Errorf("%w: reading %s: %w", ErrUnreadable, rel, err) +} + func orNone(s string) string { if s == "" { return "without a status" diff --git a/internal/core/drainrule/drainrule_test.go b/internal/core/drainrule/drainrule_test.go index c07c772dd..571d28be0 100644 --- a/internal/core/drainrule/drainrule_test.go +++ b/internal/core/drainrule/drainrule_test.go @@ -204,8 +204,8 @@ func TestLoadNeverReadsThroughALinkOutOfTheCheckout(t *testing.T) { if err := os.Symlink(filepath.Join(outside, filepath.FromSlash(ADRsRelDir)), filepath.Join(repo, filepath.FromSlash(ADRsRelDir))); err != nil { t.Fatal(err) } - if r, err := Load(repo); err == nil { - t.Fatalf("a rule was read through a link out of the checkout: %+v", r) + if r, err := Load(repo); !errors.Is(err, ErrUnreadable) { + t.Fatalf("a rule was read through a link out of the checkout: %+v, %v; want ErrUnreadable", r, err) } } @@ -234,3 +234,104 @@ func TestTheStoreIsTheDecisionStore(t *testing.T) { t.Fatalf("drainrule reads %s, decide mints into %s", ADRsRelDir, decide.ADRsRelDir) } } + +// TestAnyDuplicateKeyInARuleRecordRefuses: the line scanner keeps a key's first +// value and a YAML reader its last, so a record stating any top-level key twice +// says two things, and which one an unattended drain obeys is never the +// reader's choice. `status: accepted` then `status: superseded` is the sharp +// case: first-wins admits a record every YAML reader calls superseded. +func TestAnyDuplicateKeyInARuleRecordRefuses(t *testing.T) { + cases := map[string]struct{ status, extra, want string }{ + "status accepted then superseded": {"accepted", "status: superseded\n" + strictFields, "status"}, + "status superseded then accepted": {"superseded", "status: accepted\n" + strictFields, "status"}, + "id twice": {"accepted", "id: adr-99\n" + strictFields, "id"}, + "date twice": {"accepted", "date: 2026-10-01\n" + strictFields, "date"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "0014-rule.md", "adr-14", c.status, c.extra) + r, err := Load(repo) + if !errors.Is(err, ErrMalformed) { + t.Fatalf("got rule %+v, err %v; want ErrMalformed", r, err) + } + if !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "more than once") { + t.Errorf("the refusal does not name the duplicated key %q: %v", c.want, err) + } + }) + } +} + +// TestARuleRecordWhoseIdDisagreesWithItsFileNameRefuses: every surface names +// the rule by its record's id, so a record whose frontmatter claims another +// record's id (abcd's own strict one, say) while loosening floors would put +// that record's name on its own rule. +func TestARuleRecordWhoseIdDisagreesWithItsFileNameRefuses(t *testing.T) { + repo := t.TempDir() + writeADR(t, repo, "2609300000000001-x.md", "adr-2609291342092738", "accepted", + strings.Replace(strictFields, "handback", "take", 1)) + r, err := Load(repo) + if !errors.Is(err, ErrMalformed) { + t.Fatalf("got rule %+v, err %v; want ErrMalformed", r, err) + } + for _, want := range []string{"adr-2609291342092738", "adr-2609300000000001"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } + // The same number written with its ordinal zeros is the same id. + repo = t.TempDir() + writeADR(t, repo, "0015-rule.md", "adr-0015", "accepted", strictFields) + if r, err := Load(repo); err != nil || r.Record != "adr-0015" && r.Record != "adr-15" { + t.Errorf("a zero-padded id refused or renamed: %+v %v", r, err) + } +} + +// TestAnOversizedRecordIsNeverRead: the store is repository-authored, so every +// record in it is read through the capped reader, and one past the cap refuses +// the load rather than being read whole. +func TestAnOversizedRecordIsNeverRead(t *testing.T) { + repo := t.TempDir() + p := writeADR(t, repo, "0016-rule.md", "adr-16", "accepted", strictFields) + f, err := os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(strings.Repeat("padding line\n", (2<<20)/13)); err != nil { + t.Fatal(err) + } + f.Close() + r, err := Load(repo) + if !errors.Is(err, ErrUnreadable) { + t.Fatalf("got rule %+v, err %v; want ErrUnreadable", r, err) + } + if !strings.Contains(err.Error(), "0016-rule.md") || !strings.Contains(err.Error(), "size cap") { + t.Errorf("the refusal does not name the record and the cap: %v", err) + } +} + +// TestASymlinkedRecordInsideTheStoreIsNeverFollowed: a record in the store +// that is a link, even one resolving inside the checkout, is refused rather +// than read as a live record. +func TestASymlinkedRecordInsideTheStoreIsNeverFollowed(t *testing.T) { + repo := t.TempDir() + loose := filepath.Join(repo, "docs", "loose.md") + if err := os.MkdirAll(filepath.Dir(loose), 0o755); err != nil { + t.Fatal(err) + } + body := "---\nid: adr-17\nstatus: accepted\n" + strings.Replace(strictFields, "handback", "take", 1) + "---\n" + if err := os.WriteFile(loose, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + dir := filepath.Join(repo, filepath.FromSlash(ADRsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + // Relative, so the link resolves inside the checkout. + if err := os.Symlink(filepath.FromSlash("../../../../docs/loose.md"), filepath.Join(dir, "0017-rule.md")); err != nil { + t.Fatal(err) + } + if r, err := Load(repo); !errors.Is(err, ErrUnreadable) { + t.Fatalf("got rule %+v, err %v; want ErrUnreadable", r, err) + } +} diff --git a/internal/surface/cli/drain.go b/internal/surface/cli/drain.go index b3a554f37..945e4c7e2 100644 --- a/internal/surface/cli/drain.go +++ b/internal/surface/cli/drain.go @@ -58,7 +58,10 @@ func newDrainCommand(asJSON *bool) *cobra.Command { return &exitError{Code: 2, Msg: "abcd drain: refused to start: " + termsafe.Sanitize(err.Error()) + " (nothing written)"} } plan, err := capture.PlanDrain(capture.DrainPlanRequest{RepoRoot: repoRoot}) - if errors.Is(err, drainrule.ErrUnrecorded) || errors.Is(err, drainrule.ErrMalformed) || errors.Is(err, drainrule.ErrAmbiguous) { + // Every refusal of the rule exits 2, as the bare verb's does: a rule + // unrecorded, ambiguous, malformed, or unreadable (a link, a record + // past the size cap). + if isDrainRuleRefusal(err) { return &exitError{Code: 2, Msg: "abcd drain: " + termsafe.Sanitize(err.Error()) + " (nothing written)"} } if err != nil { @@ -78,6 +81,17 @@ func newDrainCommand(asJSON *bool) *cobra.Command { return cmd } +// isDrainRuleRefusal reports whether err is the rule load refusing: every one +// of drainrule's sentinels. +func isDrainRuleRefusal(err error) bool { + for _, s := range []error{drainrule.ErrUnrecorded, drainrule.ErrMalformed, drainrule.ErrAmbiguous, drainrule.ErrUnreadable} { + if errors.Is(err, s) { + return true + } + } + return false +} + // drainOutcomes is the order the counts line names the dispositions in. var drainOutcomes = []capture.DrainOutcome{ capture.DrainEligible, capture.DrainHandBack, capture.DrainIneligible, @@ -100,6 +114,9 @@ func renderDrainPlan(w io.Writer, plan capture.DrainPlan) { if plan.Anchor != "" { fmt.Fprintf(w, " anchor: %s (a deferral past it is live)\n", termsafe.Sanitize(plan.Anchor)) } + if plan.AnchorUnknown { + fmt.Fprintln(w, " anchor: unknown: this checkout holds no release tag (a shallow clone fetches none), so every record carrying a deferral is handed back; `git fetch --tags` and drain again") + } fmt.Fprintf(w, " order: %s\n", termsafe.Sanitize(plan.Order)) for _, v := range plan.Dispositions { kind := strings.TrimSpace(string(v.Severity) + " " + string(v.Category)) diff --git a/internal/surface/cli/drain_surface_test.go b/internal/surface/cli/drain_surface_test.go index 025ef8719..04b534f01 100644 --- a/internal/surface/cli/drain_surface_test.go +++ b/internal/surface/cli/drain_surface_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/intentdriven/abcd/internal/core/capture" "github.com/intentdriven/abcd/internal/core/drainrule" ) @@ -265,3 +266,63 @@ func TestDrainOnTheStrictRuleNamesNoLoosening(t *testing.T) { t.Errorf("the strict rule warns on stderr:\n%s", stderr.String()) } } + +// TestEveryRefusalOfTheRuleExitsTwo: a rule the drain cannot read safely (a +// store or a record that is a symlink out of the checkout) refuses with exit 2 +// on the dry run and the bare verb alike, as every other refusal of the rule +// does, and writes nothing. +func TestEveryRefusalOfTheRuleExitsTwo(t *testing.T) { + outside := t.TempDir() + loose := filepath.Join(outside, "2609300000000003-rule.md") + body := "---\nid: adr-2609300000000003\nstatus: accepted\ndrain_categories: [bug]\ndrain_severities: [minor]\ndrain_security: take\ndrain_remedy: required\n---\n" + if err := os.WriteFile(loose, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + for name, link := range map[string]func(repo string) error{ + "symlinked store": func(repo string) error { + parent := filepath.Join(repo, ".abcd", "development", "decisions") + if err := os.MkdirAll(parent, 0o755); err != nil { + return err + } + return os.Symlink(outside, filepath.Join(parent, "adrs")) + }, + "symlinked record": func(repo string) error { + dir := filepath.Join(repo, filepath.FromSlash(drainrule.ADRsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + return os.Symlink(loose, filepath.Join(dir, "2609300000000003-rule.md")) + }, + } { + t.Run(name, func(t *testing.T) { + repo := captureLedgerRepo(t) + if err := link(repo); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"drain", "--dry-run"}, {"drain", "--dry-run", "--json"}, {"drain"}} { + var stdout, stderr bytes.Buffer + code := Run(args, &stdout, &stderr) + msg := stdout.String() + stderr.String() + if code != 2 { + t.Errorf("%v exited %d, want 2:\n%s", args, code, msg) + } + if !strings.Contains(msg, "nothing written") { + t.Errorf("%v: the refusal does not say nothing was written:\n%s", args, msg) + } + } + }) + } +} + +// TestDrainDryRunSaysWhenTheAnchorIsUnknown: a checkout with no release tag +// cannot say whether a deferral is live, and the dry run says so above the +// records it hands back, naming how to fetch the tags. +func TestDrainDryRunSaysWhenTheAnchorIsUnknown(t *testing.T) { + var buf bytes.Buffer + renderDrainPlan(&buf, capture.DrainPlan{Record: "adr-1", Loosened: []string{}, AnchorUnknown: true}) + for _, want := range []string{"anchor: unknown", "no release tag", "git fetch --tags"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("the dry run does not say %q:\n%s", want, buf.String()) + } + } +} From 6683d62c8e218546e6c74a5d24f1c4ec227e16ae Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:12:34 +0100 Subject: [PATCH 25/69] fix(ahoy): ask the drain-rule offer only at a terminal, so a piped answer stream keeps its order The drain-rule category question and the offer were inserted mid-order, and the stdin prompter reads one line per question, so a scripted `ahoy install` handed the answer meant for a later question to the drain rule, could write an accepted record the script never asked for, and left the last question reading EOF. The offer now follows the itd-131 precedent of the git identity question: it is asked only when the prompter is at a terminal. Off one, resolveApproval neither asks the drain-rule category nor counts it declined, stepDrainRule does not run, the already-up-to-date check does not wait on it, and the result lists drain_rule.offered under optional_skipped, the text naming the terminal as the way to be asked. A terminal gate was chosen over a named --drain-rule flag: the record decides what an unattended agent may do, which a scripted answer is not a person's yes to, and a flag would hide the offer from the person at a terminal it is for. Refs: iss-2609300711394491 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/01-ahoy.md | 2 +- ...ffer-shifts-a-piped-ahoy-install-answer.md | 15 +++ commands/ahoy.md | 13 ++- internal/core/ahoy/apply.go | 46 +++++++--- internal/core/ahoy/drain_rule.go | 21 +++-- internal/core/ahoy/drain_rule_test.go | 91 ++++++++++++++++++- internal/core/ahoy/install_summary.go | 2 +- internal/core/ahoy/prompt_order_test.go | 27 +++++- internal/core/ahoy/statusline_install_test.go | 4 +- internal/surface/cli/cli.go | 26 ++++-- 10 files changed, 206 insertions(+), 41 deletions(-) create mode 100644 .abcd/work/issues/open/iss-2609300711394491-the-drain-rule-offer-shifts-a-piped-ahoy-install-answer.md diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index 4795ac2b8..77b1ad915 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -466,7 +466,7 @@ about, one question per category present, never one per item. | `dependency` | a tool a capability uses and cannot find: gitleaks, optional over the native secret scanner and required where the repository armed it in `.abcd/config/gitleaks.json` | the category approval reaches the step; each tool is then explained from the tool registry (what it is, optional or required here, what works without it, the exact install step, what the install does) and its install step runs only on a per-tool yes — typed at a terminal, or relayed by a host as a flag naming the tool — never under the approve-everything flag, a piped answer or CI; a no is reported as what the capability continues on | | `status-line` | the offer of abcd's status line in the host harness | an advisory offer asked after its own question, written only on an answered consent; never under the approve-everything flag, and reported as optional work it skipped | | `oracle-routing` | the offer of abcd's proposed model-tier routing table (itd-2609170822093401): the machine's `~/.abcd/oracle-routing.json`, then, as a separate question, the repository's `.abcd/config/oracle-routing.json` | the proposal rendered as a table (agent, tier, fan-out) and each file written only on its own answered consent, the machine one owner-only; never under the approve-everything flag, and reported as optional work it skipped; a decline records nothing, so the next install offers again; uninstall leaves both files | -| `drain-rule` | the offer of the repository's drain eligibility record (ruling BX2, itd-82): abcd's strict baseline as an accepted decision record carrying the four `drain_` fields, minted through the decision store's seam | the rule stated in one question and the record written only on an answered consent; never under the approve-everything flag, and reported as optional work it skipped; a decline records nothing, so the next install offers again; raised only while no accepted record states the rule, so a record stating it badly is never offered a second; only ever the baseline, never a loosened rule | +| `drain-rule` | the offer of the repository's drain eligibility record (ruling BX2, itd-82): abcd's strict baseline as an accepted decision record carrying the four `drain_` fields, minted through the decision store's seam | the rule stated in one question and the record written only on a consent answered at a terminal; never under the approve-everything flag and never off a terminal, where neither its category nor the offer is asked (so a piped answer stream keeps its order), and reported as optional work it skipped; a decline records nothing, so the next install offers again; raised only while no accepted record states the rule, so a record stating it badly is never offered a second; only ever the baseline, never a loosened rule | | `user-state` | the registry entry, re-founding, stale or duplicate entries | guided; never auto-edit user-scope state, report extras read-only | **The artefact kind is a gap until it is declared** (itd-2609150819432059). A diff --git a/.abcd/work/issues/open/iss-2609300711394491-the-drain-rule-offer-shifts-a-piped-ahoy-install-answer.md b/.abcd/work/issues/open/iss-2609300711394491-the-drain-rule-offer-shifts-a-piped-ahoy-install-answer.md new file mode 100644 index 000000000..f7204ec8c --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300711394491-the-drain-rule-offer-shifts-a-piped-ahoy-install-answer.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300711394491" +slug: "the-drain-rule-offer-shifts-a-piped-ahoy-install-answer" +severity: "major" +category: "bug" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/drain_rule.go" +remedy: "Ask the drain-rule category and offer only when the prompter is at a terminal (the itd-131 TerminalPrompter precedent the git identity step set), neither asking nor counting it declined off one, and list drain_rule.offered under optional_skipped naming the terminal as the way to be asked; test that a piped stream of the pre-change answer count gets the pre-change questions and writes no record." +--- + +The drain-rule offer shifts a piped ahoy install answer stream: the drain-rule category question and the offer itself are inserted mid-order, and the stdin prompter reads one line per question with no terminal gate, so in every adopter repository a scripted stream hands the answer meant for a later question (user-state, the identity pin) to the drain rule, can write an accepted drain eligibility record the script never asked for, and leaves the later question reading EOF. diff --git a/commands/ahoy.md b/commands/ahoy.md index f8f01f7d4..a12fbd6fb 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -148,7 +148,8 @@ reads end-of-input and DECLINES. `yes` is the reliable form because it never runs out; a single `printf 'y\n'` answers the first question only and silently declines the rest. The questions come in a fixed order (dependency, safe-autocreate, config-change, status-line, oracle-routing, drain-rule, user-state, plugin-owned), so a -scripted stream of specific answers lines up with them. Each answer is echoed back, so the +scripted stream of specific answers lines up with them. The drain-rule question +is asked only at a terminal, so a piped stream never meets it. Each answer is echoed back, so the transcript shows what was asked and what it was answered — read it back rather than assuming. Under `set -o pipefail` the pipeline reports 141: `yes` takes SIGPIPE when abcd stops reading, by design — judge the run by abcd's own output @@ -188,7 +189,8 @@ harness-wide setting, never accepts a model-tier routing table (below), because a table decides which model every delegated step asks for, and never adds the drain eligibility record (below), because the record decides what an unattended agent may change in the repository. When the result carries `optional_skipped`, report it and -offer the `yes |` form above as the way to apply it. +offer the `yes |` form above as the way to apply it, except `drain_rule.offered`, +which only a person at a terminal is asked. **The git identity question is a person's alone.** When the author or committer a commit would carry diverges from the identity pin, or is a machine identity @@ -312,8 +314,11 @@ blocks it; every security, major and critical issue is a person's); consent mints it through the decision store's own seam as an accepted record, which is committed with the repository. Relay the user's answer; never answer it for the user. Declining writes nothing and records nothing, so the next install offers -again; `--yes` skips the offer and reports `drain_rule.offered` under -`optional_skipped`; `yes |` accepts it. The offer only ever writes the baseline: +again. It is asked only at a terminal, as the git identity question is: off one +(a pipe, a routine, CI) neither its category nor the offer is asked, so a +scripted answer stream keeps its order and a scripted yes never writes the +record; that run, and a `--yes` run, report `drain_rule.offered` under +`optional_skipped`. The offer only ever writes the baseline: loosening a floor is an edit a person makes to the record, and `abcd drain` names every floor loosened. A repository whose record states the rule badly is not offered a second one; `abcd drain` names what is wrong with the one it has. diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index c2107c25e..5a12f4126 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -10,6 +10,7 @@ import ( "path/filepath" "github.com/intentdriven/abcd/internal/fsutil" + "slices" "sort" "strings" "time" @@ -106,7 +107,7 @@ func install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) modeForced := modeWouldChange(opts, det, binTargetPath) if len(actionable(det.Gaps)) == 0 && - !(!opts.Yes && len(optionalPending(det.Gaps)) > 0) && + !(!opts.Yes && len(optionalAskable(det.Gaps, p)) > 0) && !overridesWouldChange(abs, opts.ValueOverrides) && !attributionWouldChange(abs, opts) && !modeForced { @@ -123,12 +124,12 @@ func install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) return InstallResult{ Status: "partial", Notes: malformedConfigNotes(cfgErr, opts.ValueOverrides), - OptionalSkipped: optionalSkipped(opts, det.Gaps), + OptionalSkipped: optionalSkipped(opts, det.Gaps, p), }, nil } return InstallResult{ Status: "already_up_to_date", - OptionalSkipped: optionalSkipped(opts, det.Gaps), + OptionalSkipped: optionalSkipped(opts, det.Gaps, p), }, nil } @@ -222,7 +223,7 @@ func install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) Remaining: remaining, DeclinedCategories: declined, Notes: ac.notes, - OptionalSkipped: optionalSkipped(opts, final.Gaps), + OptionalSkipped: optionalSkipped(opts, final.Gaps, p), writeKinds: ac.writeKinds, }, nil } @@ -1784,16 +1785,30 @@ const credentialAtRestGapID = "history.credential_at_rest" // stepDrainRule). In the order they are reported. var optionalGapIDs = []string{OptionalPinGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID, DrainRuleOfferGapID} -// optionalSkipped lists the optional gaps a --yes run left un-applied. --yes -// approves every resolvable category but never adopts the identity pin or -// wires the status line, so the skip is deliberate — and therefore has to be -// reported rather than left ambient (iss-166). Outside --yes each is offered -// as a confirmation, so nothing is skipped silently and the list stays empty. -func optionalSkipped(opts InstallOptions, gaps []Gap) []string { - if !opts.Yes { +// optionalSkipped lists the optional gaps a run left un-applied without asking. +// --yes approves every resolvable category but never adopts the identity pin +// or wires the status line, so the skip is deliberate — and therefore has to +// be reported rather than left ambient (iss-166). Outside --yes each is offered +// as a confirmation, except the drain eligibility record off a terminal (see +// stepDrainRule), so that one is listed and nothing is skipped silently. +func optionalSkipped(opts InstallOptions, gaps []Gap, p Prompter) []string { + if opts.Yes { + return optionalPending(gaps) + } + if atTerminal(p) || !gapIDSet(gaps)[DrainRuleOfferGapID] { return nil } - return optionalPending(gaps) + return []string{DrainRuleOfferGapID} +} + +// optionalAskable is optionalPending less the offers that will not be asked of +// p: the drain eligibility record is offered only to a person at a terminal. +func optionalAskable(gaps []Gap, p Prompter) []string { + pending := optionalPending(gaps) + if atTerminal(p) { + return pending + } + return slices.DeleteFunc(pending, func(id string) bool { return id == DrainRuleOfferGapID }) } // optionalPending reports which of the optional gaps are the remaining work. @@ -1911,6 +1926,13 @@ func resolveApproval(gaps []Gap, opts InstallOptions, p Prompter) (map[GapCatego approved[c] = true } default: + // The drain eligibility record is offered only to a person at a + // terminal (see stepDrainRule): off one its category is neither asked + // nor counted as declined, so a piped answer stream keeps the order it + // had before the offer existed. + if !atTerminal(p) { + delete(present, DrainRule) + } for _, c := range presentInPromptOrder(present) { if c == Dependency && opts.ApproveDependency { continue // answered by the named tool; approved below diff --git a/internal/core/ahoy/drain_rule.go b/internal/core/ahoy/drain_rule.go index 6e32992c9..7f2380667 100644 --- a/internal/core/ahoy/drain_rule.go +++ b/internal/core/ahoy/drain_rule.go @@ -15,12 +15,16 @@ package ahoy // it through the decision store's own seam as an accepted record, which is // committed with the repository and decides for everyone who drains it. // -// Like the routing offers, it is asked only at an answered prompt: --yes -// approves the category but never writes the record, because the record -// decides what an unattended agent may do in this repository, and it is -// reported under optional_skipped. A decline writes nothing and records -// nothing, so the next install offers again. The offer only ever writes the -// baseline; loosening a floor is an edit a person makes to the record. +// It is asked only of a person at a terminal (the itd-131 precedent the git +// identity step set): --yes approves the category but never writes the record, +// and off a terminal (a pipe, a routine, CI) neither the category nor the offer +// is asked, because the record decides what an unattended agent may do in this +// repository and a scripted yes is not a person's. Adding the question to a +// piped answer stream would also shift every later answer onto the wrong +// question. Either way it is reported under optional_skipped. A decline writes +// nothing and records nothing, so the next install offers again. The offer +// only ever writes the baseline; loosening a floor is an edit a person makes +// to the record. import ( "errors" @@ -58,9 +62,10 @@ func detectDrainRule(cwd string) []Gap { }} } -// stepDrainRule makes the offer. It never runs under --yes. +// stepDrainRule makes the offer. It never runs under --yes, and never off a +// terminal. func (a *applyCtx) stepDrainRule() { - if a.autoYes || !a.approved[DrainRule] || !a.has(DrainRuleOfferGapID) { + if a.autoYes || !atTerminal(a.prompter) || !a.approved[DrainRule] || !a.has(DrainRuleOfferGapID) { return } if !a.prompter.Confirm(drainRuleQuestion()) { diff --git a/internal/core/ahoy/drain_rule_test.go b/internal/core/ahoy/drain_rule_test.go index bf739f31f..541c93fcc 100644 --- a/internal/core/ahoy/drain_rule_test.go +++ b/internal/core/ahoy/drain_rule_test.go @@ -15,10 +15,16 @@ import ( // drain refuses there until it does"). The offer writes abcd's strict baseline // as an accepted decision record, and only on the person's own yes. +// terminalScripted is a scriptedPrompter a person answers at a terminal, the +// only prompter the drain rule offer is put to. +type terminalScripted struct{ *scriptedPrompter } + +func (terminalScripted) AtTerminal() bool { return true } + // drainRulePrompter approves every category question and answers the drain -// rule offer as told; every other offer is declined. -func drainRulePrompter(yes bool) *scriptedPrompter { - return &scriptedPrompter{confirm: func(q string) bool { +// rule offer as told, at a terminal; every other offer is declined. +func drainRulePrompter(yes bool) terminalScripted { + return terminalScripted{&scriptedPrompter{confirm: func(q string) bool { switch { case strings.HasPrefix(q, "Apply "): return true @@ -26,7 +32,7 @@ func drainRulePrompter(yes bool) *scriptedPrompter { return yes } return false - }} + }}} } // TestDrainRuleOfferWritesTheBaselineOnYes: a managed repository without the @@ -146,3 +152,80 @@ func TestDrainRuleOfferLeavesAMalformedRecordAlone(t *testing.T) { t.Error("a repository stating a malformed rule is offered a second record") } } + +// pipedPrompter is a scripted answer stream off a pipe: each confirm takes the +// next answer, and past the end reads EOF, a no, as the CLI's stdin prompter +// does. It is not a TerminalPrompter, so no person is at a terminal. +type pipedPrompter struct { + answers []bool + asked []string +} + +func (p *pipedPrompter) Confirm(q string) bool { + p.asked = append(p.asked, q) + if len(p.asked) > len(p.answers) { + return false + } + return p.answers[len(p.asked)-1] +} + +func (p *pipedPrompter) Prompt(_ string, _ []string, def string) string { return def } + +// TestAPipedInstallStreamIsNotShiftedByTheDrainRuleOffer: a scripted `ahoy +// install` answers its questions positionally, so a question added to the +// sequence hands every later answer to the wrong question, and a yes meant for +// another question would write the drain rule, which decides what an unattended +// agent may change. Off a terminal the offer is never asked (the itd-131 +// precedent): a stream of the answer count a repository holding the record is +// asked gets the same questions in the same order, the last of them still gets +// its answer, no record is written, and optional_skipped names the offer. +func TestAPipedInstallStreamIsNotShiftedByTheDrainRuleOffer(t *testing.T) { + var control []string + t.Run("control: the repository holds the record", func(t *testing.T) { + setupHermetic(t) + repo := installedRepo(t) + dir := filepath.Join(repo, filepath.FromSlash(drainrule.ADRsRelDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := "---\nid: adr-2609300000000009\nstatus: accepted\n" + drainrule.ProposalFrontmatter() + "---\n" + if err := os.WriteFile(filepath.Join(dir, "2609300000000009-drain-rule.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + p := &pipedPrompter{answers: make([]bool, 64)} + for i := range p.answers { + p.answers[i] = true + } + if _, err := Install(repo, InstallOptions{}, p); err != nil { + t.Fatal(err) + } + control = append(control, p.asked...) + }) + if len(control) == 0 { + t.Fatal("the control install asked nothing; the stream has nothing to shift") + } + setupHermetic(t) + repo := installedRepo(t) + p := &pipedPrompter{answers: make([]bool, len(control))} + for i := range p.answers { + p.answers[i] = true + } + res, err := Install(repo, InstallOptions{}, p) + if err != nil { + t.Fatal(err) + } + if strings.Join(p.asked, "\n") != strings.Join(control, "\n") { + t.Errorf("the piped stream was shifted:\n got %q\nwant %q", p.asked, control) + } + for _, q := range p.asked { + if strings.Contains(q, string(DrainRule)) || strings.Contains(q, drainRuleQuestionTail) { + t.Errorf("a pipe was asked the drain rule: %q", q) + } + } + if _, err := drainrule.Load(repo); !errors.Is(err, drainrule.ErrUnrecorded) { + t.Errorf("a piped stream wrote a drain rule: %v", err) + } + if !containsString(res.OptionalSkipped, DrainRuleOfferGapID) { + t.Errorf("optional_skipped = %v, want it to name %s", res.OptionalSkipped, DrainRuleOfferGapID) + } +} diff --git a/internal/core/ahoy/install_summary.go b/internal/core/ahoy/install_summary.go index d82bb19b6..f0995813e 100644 --- a/internal/core/ahoy/install_summary.go +++ b/internal/core/ahoy/install_summary.go @@ -223,7 +223,7 @@ var optionalSkippedHelp = map[string]SummaryItem{ DrainRuleOfferGapID: { What: "The rule for which open issues abcd drain may fix without asking you was not recorded.", Why: "The rule decides what an unattended agent may change in this repository, so it needs your own yes; until it is recorded, abcd drain refuses to run here.", - Action: "Run abcd ahoy install without --yes and answer the question about the drain rule.", + Action: "Run abcd ahoy install at a terminal, without --yes, and answer the question about the drain rule.", }, } diff --git a/internal/core/ahoy/prompt_order_test.go b/internal/core/ahoy/prompt_order_test.go index 619dad9a1..ecc7fc2b5 100644 --- a/internal/core/ahoy/prompt_order_test.go +++ b/internal/core/ahoy/prompt_order_test.go @@ -12,8 +12,12 @@ import ( type recordingPrompter struct { asked []string confirm bool + // terminal makes it a TerminalPrompter a person answers at a terminal. + terminal bool } +func (p *recordingPrompter) AtTerminal() bool { return p.terminal } + func (p *recordingPrompter) Confirm(q string) bool { p.asked = append(p.asked, q) return p.confirm @@ -56,12 +60,31 @@ func TestResolveApprovalPromptsInCanonicalOrder(t *testing.T) { "Apply plugin-owned changes?", } for i := 0; i < 64; i++ { - p := &recordingPrompter{confirm: true} + p := &recordingPrompter{confirm: true, terminal: true} resolveApproval(allCategoryGaps(), InstallOptions{}, p) if strings.Join(p.asked, "|") != strings.Join(want, "|") { t.Fatalf("run %d asked in a different order:\n got %v\nwant %v", i, p.asked, want) } } + // Off a terminal the drain-rule question is not asked (stepDrainRule), and + // not counted as declined: the rest keep their order, so a piped stream + // written before the offer existed still lines up. + offTerminal := make([]string, 0, len(want)) + for _, q := range want { + if q != "Apply drain-rule changes?" { + offTerminal = append(offTerminal, q) + } + } + p := &recordingPrompter{confirm: false} + _, declined := resolveApproval(allCategoryGaps(), InstallOptions{}, p) + if strings.Join(p.asked, "|") != strings.Join(offTerminal, "|") { + t.Fatalf("off a terminal:\n got %v\nwant %v", p.asked, offTerminal) + } + for _, c := range declined { + if c == string(DrainRule) { + t.Fatalf("off a terminal the unasked drain-rule category is reported declined: %v", declined) + } + } } // TestCategoryPromptOrderCoversEveryCategory keeps the canonical order honest: @@ -95,7 +118,7 @@ func TestResolveApprovalAsksUnknownCategoriesLast(t *testing.T) { Gap{ID: "alpha.a", Category: GapCategory("alpha"), Resolvable: true}, ) for i := 0; i < 32; i++ { - p := &recordingPrompter{confirm: true} + p := &recordingPrompter{confirm: true, terminal: true} resolveApproval(gaps, InstallOptions{}, p) if len(p.asked) != 10 { t.Fatalf("asked %d questions, want 10: %v", len(p.asked), p.asked) diff --git a/internal/core/ahoy/statusline_install_test.go b/internal/core/ahoy/statusline_install_test.go index 1ec3dd824..ff2a883d9 100644 --- a/internal/core/ahoy/statusline_install_test.go +++ b/internal/core/ahoy/statusline_install_test.go @@ -774,10 +774,10 @@ func TestStatusLineCategoryIsOptionalForYes(t *testing.T) { t.Fatal("StatusLine is not in categoryPromptOrder") } gaps := []Gap{{ID: StatusLineOfferGapID, Category: StatusLine, Resolvable: true}} - if got := optionalSkipped(InstallOptions{Yes: true}, gaps); strings.Join(got, ",") != StatusLineOfferGapID { + if got := optionalSkipped(InstallOptions{Yes: true}, gaps, nil); strings.Join(got, ",") != StatusLineOfferGapID { t.Errorf("optionalSkipped under --yes = %v", got) } - if got := optionalSkipped(InstallOptions{}, gaps); len(got) != 0 { + if got := optionalSkipped(InstallOptions{}, gaps, nil); len(got) != 0 { t.Errorf("optionalSkipped without --yes = %v, want none (it is offered)", got) } } diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 4bb11a1ca..0396c82d9 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3438,16 +3438,27 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { fmt.Fprintf(w, " remaining gaps: %s\n", strings.Join(res.Remaining, ", ")) } // --yes approves every category but never writes the identity - // pin, the status-line wiring or a routing table, so say which optional work it - // left, why each needs an answer, and how to apply it. + // pin, the status-line wiring, a routing table or the drain + // rule, and off a terminal the drain rule is not asked at all, + // so say which optional work it left, why each needs an answer, + // and how to apply it. if len(res.OptionalSkipped) > 0 { - fmt.Fprintf(w, " optional, not covered by --yes: %s\n", strings.Join(res.OptionalSkipped, ", ")) + label := "optional, not covered by --yes" + if !yes { + label = "optional, asked only at a terminal" + } + fmt.Fprintf(w, " %s: %s\n", label, strings.Join(res.OptionalSkipped, ", ")) for _, id := range res.OptionalSkipped { if why := optionalSkipReason(id); why != "" { fmt.Fprintf(w, " %s\n", why) } } - fmt.Fprint(w, " run `abcd ahoy install` (no --yes) and answer y at each prompt — non-interactively, `yes | abcd ahoy install`\n") + if yes { + fmt.Fprint(w, " run `abcd ahoy install` (no --yes) and answer y at each prompt — non-interactively, `yes | abcd ahoy install`\n") + } + if slices.Contains(res.OptionalSkipped, ahoy.DrainRuleOfferGapID) { + fmt.Fprint(w, " the drain rule is asked only of a person at a terminal: run `abcd ahoy install` there, without --yes, and answer it\n") + } } }) }, @@ -3776,7 +3787,7 @@ func optionalSkipReason(id string) string { case ahoy.OracleRoutingMachineGapID, ahoy.OracleRoutingRepoGapID: return "a routing table decides which model every delegated step asks for, so abcd's proposal is only accepted against an answered prompt" case ahoy.DrainRuleOfferGapID: - return "the drain eligibility record decides what an unattended agent may change in this repository, so it is only added against an answered prompt" + return "the drain eligibility record decides what an unattended agent may change in this repository, so it is only added against a prompt answered at a terminal" } return "" } @@ -3885,8 +3896,9 @@ func (p *stdinPrompter) echo(answer string) { } // AtTerminal reports whether a person is answering at a terminal, which makes -// the prompter an ahoy.TerminalPrompter: the one question abcd asks only of a -// person (whether to change who commits, itd-131) is never put to a pipe. +// the prompter an ahoy.TerminalPrompter: the questions abcd asks only of a +// person (whether to change who commits, itd-131, and whether to record the +// drain eligibility rule) are never put to a pipe. func (p *stdinPrompter) AtTerminal() bool { return p.tty } func (p *stdinPrompter) Confirm(question string) bool { From 1f7b436ef9b4a6f462c97a82731c06a9b2ebd20a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:12:36 +0100 Subject: [PATCH 26/69] docs(decisions): record the terminal-only drain-rule offer, the unknown anchor and the stricter rule reader One entry appended after the drainOwnRule entry, correcting three of its points after review: the drain-rule offer is asked only at a terminal (and why that over a --drain-rule flag), a checkout with no release tag marks the anchor unknown and hands back every record carrying a deferral, and the rule's reader refuses any duplicated key, an id its file name contradicts, a linked record and one past the size cap. Refs: iss-2609300711394491, iss-2609300711394709, iss-2609300711402515 Assisted-by: Claude:claude-opus-5-5 --- .abcd/work/DECISIONS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index b176f355c..ae9ac1be8 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2588,3 +2588,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-29 — Every new issue carries a remedy, and abcd's own automatic filers write one machine value when they have no fix (the product thinker's rulings BX3 and H12 of 2026-09-29, applied by lane remedyRequired of autonomous run A; partial of itd-82, whose decision 6 already required the field). BX3, verbatim: "REFUSE the filing; every new issue must carry remedy:". H12, verbatim: "'NO FIX YET' ALLOWED: automatic filers may write remedy 'none (filed automatically)'; the record is filed, drain skips it until a person writes a real remedy." As built: `capture` refuses a new issue with no remedy or a blank one, exit 2 and nothing written, naming `--remedy` and the machine value; the value is spelt once, `issueschema.MachineRemedy`, and written by every in-binary filer (the consistency pass, and an inbox report promoted without a remedy of its own, whose own remedy otherwise becomes the issue's); `abcd drain --dry-run` lists a record carrying it as ineligible, naming the automatic filer and the verb that answers it; and `capture remedy <iss-N> "<fix>"` writes or replaces the remedy on an open issue. A record filed before the rule carries none, stays readable and valid, and is listed as ineligible. The one-line capture friction that `commands/capture.md` and the principle `adversarial-review-scales-with-blast-radius` promised is tightened by the ruling, and both now say so. A person typing the machine value is REFUSED, by `capture --remedy` and by `capture remedy`, compared trimmed and case-folded: the value is useful only while it means that a machine filed the record, and a person has either a fix to name or the choice not to file; allowing it would let a hand-filed record pass as machine-filed and be skipped silently. A remedy chosen in an autonomous run cites its grounds, a prior-art or state-of-the-art check where the fix depends on outside practice (principle `prefer-sota`), in the capture's text; nothing checks that mechanically yet. - 2026-09-30 — Pending the person's ruling CL1, a promoted inbox report files the machine value: outside text never becomes a drain-eligible remedy without a person naming it (fix round of lane remedyRequired, autonomous run A, closing the review's trust finding on the entry of 2026-09-29 above). As built: `inbox promote` always writes `remedy: none (filed automatically)` (`issueschema.MachineRemedy`), whatever the report proposes, so `abcd drain --dry-run` lists the issue as ineligible; the sender's proposal stays in the issue's text under "Remedy the reporter proposes:", scrubbed like every other value the report carries, for a person to adopt with `capture remedy`. This narrows the entry above, which filed a report's own remedy as the issue's; the ruling CL1 may widen it again. - 2026-09-30 — The drain reads the drained repository's own eligibility record, which may loosen abcd's floors loudly, and it hands back every record still waiting on a person (the product thinker's rulings BX2 and H11 of 2026-09-29, applied by lane drainOwnRule of autonomous run A; partial of itd-82, whose spec stays open for the host judgement, the lane, the hand-back writes and the pace). BX2, verbatim: "the PROJECT MUST HOLD the eligibility decision in its own record (e.g. added at setup); drain refuses there until it does." H11, verbatim: "MAY LOOSEN abcd's floors (a project may let drain take major/critical and security issues). NOTE for the lane: make a loosened floor loud (drain --dry-run and the drain start name every floor the project loosened), and keep abcd's own repository at the stricter default." As built: the record is the one accepted decision record in the repository's `.abcd/development/decisions/adrs/` whose frontmatter carries `drain_categories` (an inline list, a subset of the fixable set), `drain_severities` (an inline list of severities), `drain_security` (`handback` or `take`) and `drain_remedy` (`required`, its only value, since the remedy is the brief a lane works from); abcd's own adr-2609291342092738 carries the strict baseline, which the binary also bundles as the measure a loosening is named against, and a test fails if abcd's record loosens anything. A repository without such a record, with one that is only proposed or superseded, with two accepted, or with one that misses, misspells, repeats or mis-values a field, is refused by `drain --dry-run` and bare `drain` alike, exit 2 and nothing written, never falling back to the baseline or a looser rule; widening the categories past the fixable set is refused as a decision by kind, which H11 does not name. Every loosened floor (`severity major`, `severity critical`, `security`) is named in the dry run's text, on stderr in both output modes, in `--json` as `loosened`, and in the start's refusal. `ahoy install` offers the baseline as an accepted record, written through the decision store's mint only on an answered yes; `--yes` skips it and reports `drain_rule.offered` under `optional_skipped`, as the routing offers are. The gap the remedy lanes found (50 of 54 dry-run-eligible records waiting on a ruling) is closed by BOTH hand-backs, each its own rule: a remedy opening "Waits on" (compared case-folded) is handed back as `waits-on-ruling`, because taking it would make the ruling the remedy waits on; and a record whose `deferred_after` names the current anchor tag is handed back as `deferred`, because a person carried it past this release and the waiver is that person's decision for the cycle. Both hold whatever the repository's record says. `capture defer` writes a deferral only onto a `major` or `critical` record, which H11 now lets a record take, but this ledger also carries hand-written deferrals on minor records (62 of the 231 open records on this branch are handed back as `deferred`), and the rule holds them back the same way. They are asked after the category and severity hand-backs, whose fix a ruling or a lapse would not change, and the ruling before the deferral, because it names which decision is owed; a record carrying both waits on both. The release tags are read only when an open record carries a deferral, and a failure to read them refuses the plan rather than letting a live deferral through. The threat is stated in the drain brief chapter: the record is a repository-authored file deciding what an unattended agent may do, so a contributor's pull request can loosen it; what guards it is that the record is committed history reviewed like code, a loosening is loud on every run, abcd's own repository keeps the baseline under a test, and the store is read inside the checkout so a symlink leaving it is refused. +- 2026-09-30 — Correcting three points of the entry above after its review (lane fix-drainOwnRule of autonomous run A). The drain-rule offer of `ahoy install` is asked only of a person at a terminal, the itd-131 precedent the git identity question set, rather than behind a named opt-in flag: off a terminal neither its category question nor the offer is asked, so a piped answer stream keeps the order it had before the offer existed and a scripted yes never writes the record, and the run reports `drain_rule.offered` under `optional_skipped` naming the terminal as the way to be asked. The terminal gate was chosen over a `--drain-rule` flag because the record decides what an unattended agent may do, which a scripted answer is not a person's yes to, and a flag would hide the offer from the person at a terminal it is for. A checkout holding no release tag (a shallow clone fetches none) marks the anchor unknown rather than reading every deferral as lapsed: every record carrying a deferral is handed back as `deferred`, naming the missing tags and `git fetch --tags`, which keeps the rest of the dry run readable where refusing the whole plan would not. The rule's reader refuses, as malformed, a record that states any frontmatter key twice (not only a `drain_` key) and one whose frontmatter `id` disagrees with its file name, and reads each record through the capped trust-boundary reader, so a record that is a symlink or past the size cap refuses; every refusal of the rule exits 2 on the dry run as on the bare verb. From b8ae7c46e6c3263b90c3c2790ceaabf9a4a20b58 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:13:02 +0100 Subject: [PATCH 27/69] =?UTF-8?q?chore:=20resolve=20iss-2609300711394709,?= =?UTF-8?q?=20iss-2609300711402515=20=E2=80=94=20the=20drain's=20rule=20re?= =?UTF-8?q?ader=20and=20tagless=20anchor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both fixed in 788f0a550: a checkout with no release tag hands back every record carrying a deferral, and the rule's reader refuses any duplicated key, a contradicted id, a linked record and one past the size cap. Resolves: iss-2609300711394709 Resolves: iss-2609300711402515 Assisted-by: Claude:claude-opus-5-5 --- ...rain-lets-a-live-deferral-through-when-the-checkout.md | 8 ++++++++ ...n-eligibility-reader-admits-a-candidate-record-that.md | 8 ++++++++ 2 files changed, 16 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609300711394709-abcd-drain-lets-a-live-deferral-through-when-the-checkout.md (68%) rename .abcd/work/issues/{open => resolved}/iss-2609300711402515-the-drain-eligibility-reader-admits-a-candidate-record-that.md (69%) diff --git a/.abcd/work/issues/open/iss-2609300711394709-abcd-drain-lets-a-live-deferral-through-when-the-checkout.md b/.abcd/work/issues/resolved/iss-2609300711394709-abcd-drain-lets-a-live-deferral-through-when-the-checkout.md similarity index 68% rename from .abcd/work/issues/open/iss-2609300711394709-abcd-drain-lets-a-live-deferral-through-when-the-checkout.md rename to .abcd/work/issues/resolved/iss-2609300711394709-abcd-drain-lets-a-live-deferral-through-when-the-checkout.md index 40e97cd21..86c5ce60e 100644 --- a/.abcd/work/issues/open/iss-2609300711394709-abcd-drain-lets-a-live-deferral-through-when-the-checkout.md +++ b/.abcd/work/issues/resolved/iss-2609300711394709-abcd-drain-lets-a-live-deferral-through-when-the-checkout.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/capture/eligible.go" remedy: "When an open record carries a deferral and no release tag is found, mark the anchor unknown and hand back every record carrying a deferral under the deferred rule, naming the missing tags and git fetch --tags, with the dry run saying the anchor is unknown; keep the refusal on a failed tag read; test on a --depth 1 --no-tags clone." +resolution: "Resolved: with no release tag in the checkout the anchor is marked unknown and every record carrying a deferral is handed back as deferred, naming git fetch --tags; TestADeferralIsHandedBackWhenTheCheckoutHoldsNoReleaseTag on a --depth 1 --no-tags clone." +impact: fix +resolved_by: + commit: "788f0a550" --- abcd drain lets a live deferral through when the checkout holds no release tag: liveDeferralAnchor returns an empty anchor when no tag is found, so every deferred_after reads as lapsed and a record a person carried past this release is eligible. A shallow or tagless clone (fetch-depth 1 fetches no tags) is the unattended drain's likely checkout, and the comment above the function promised the opposite. + +## Grounds + +- pursued: a tagless clone hands back every deferred record and the dry run says the anchor is unknown; a deferred record reported eligible on a tagless clone would show it wrong diff --git a/.abcd/work/issues/open/iss-2609300711402515-the-drain-eligibility-reader-admits-a-candidate-record-that.md b/.abcd/work/issues/resolved/iss-2609300711402515-the-drain-eligibility-reader-admits-a-candidate-record-that.md similarity index 69% rename from .abcd/work/issues/open/iss-2609300711402515-the-drain-eligibility-reader-admits-a-candidate-record-that.md rename to .abcd/work/issues/resolved/iss-2609300711402515-the-drain-eligibility-reader-admits-a-candidate-record-that.md index df9c028e3..910adaea3 100644 --- a/.abcd/work/issues/open/iss-2609300711402515-the-drain-eligibility-reader-admits-a-candidate-record-that.md +++ b/.abcd/work/issues/resolved/iss-2609300711402515-the-drain-eligibility-reader-admits-a-candidate-record-that.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/drainrule/drainrule.go" remedy: "Refuse, as malformed, any candidate record that states any top-level key twice (Duplicates already returns them) whatever its status reads as, and one whose frontmatter id disagrees with the id its file name gives it; read each record through fsutil.ReadGuardedInRoot with issueschema.RecordReadLimit so a link or an oversized record refuses; map every refusal of the rule load to exit 2 on the dry run." +resolution: "Resolved: any duplicated top-level key and a frontmatter id its file name contradicts refuse as malformed; each record is read through fsutil.ReadGuardedInRoot under the record cap; every rule refusal exits 2 on the dry run. Tests in drainrule and cli drain_surface_test." +impact: fix +resolved_by: + commit: "788f0a550" --- The drain eligibility reader admits a candidate record that states a non-drain key twice: drainrule.Load narrowed frontmatter.Duplicates to drain_ keys, so status: accepted followed by status: superseded loads and applies on the line scanner's first-wins reading, while any YAML reader (last wins) calls the record superseded. The same reader read every store record uncapped (root.ReadFile) and trusted a frontmatter id that disagrees with the file name. + +## Grounds + +- pursued: status accepted then superseded, a mismatched id, a linked record and a record past 1 MiB each refuse the load; any of them yielding a rule would show it wrong From b93f4cdd3c95aff9bb172479badd1aec5f22f83e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:13:03 +0100 Subject: [PATCH 28/69] =?UTF-8?q?chore:=20resolve=20iss-2609300711394491?= =?UTF-8?q?=20=E2=80=94=20the=20drain-rule=20offer=20is=20asked=20only=20a?= =?UTF-8?q?t=20a=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed in 6683d62c8: off a terminal the drain-rule category and offer are neither asked nor counted declined, so a piped answer stream keeps its order, and drain_rule.offered is listed under optional_skipped. Resolves: iss-2609300711394491 Assisted-by: Claude:claude-opus-5-5 --- ...drain-rule-offer-shifts-a-piped-ahoy-install-answer.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609300711394491-the-drain-rule-offer-shifts-a-piped-ahoy-install-answer.md (71%) diff --git a/.abcd/work/issues/open/iss-2609300711394491-the-drain-rule-offer-shifts-a-piped-ahoy-install-answer.md b/.abcd/work/issues/resolved/iss-2609300711394491-the-drain-rule-offer-shifts-a-piped-ahoy-install-answer.md similarity index 71% rename from .abcd/work/issues/open/iss-2609300711394491-the-drain-rule-offer-shifts-a-piped-ahoy-install-answer.md rename to .abcd/work/issues/resolved/iss-2609300711394491-the-drain-rule-offer-shifts-a-piped-ahoy-install-answer.md index f7204ec8c..efeee2d69 100644 --- a/.abcd/work/issues/open/iss-2609300711394491-the-drain-rule-offer-shifts-a-piped-ahoy-install-answer.md +++ b/.abcd/work/issues/resolved/iss-2609300711394491-the-drain-rule-offer-shifts-a-piped-ahoy-install-answer.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/drain_rule.go" remedy: "Ask the drain-rule category and offer only when the prompter is at a terminal (the itd-131 TerminalPrompter precedent the git identity step set), neither asking nor counting it declined off one, and list drain_rule.offered under optional_skipped naming the terminal as the way to be asked; test that a piped stream of the pre-change answer count gets the pre-change questions and writes no record." +resolution: "Resolved: the drain-rule category and offer are asked only at a terminal; off one they are neither asked nor counted declined and drain_rule.offered is listed under optional_skipped. TestAPipedInstallStreamIsNotShiftedByTheDrainRuleOffer." +impact: fix +resolved_by: + commit: "6683d62c8" --- The drain-rule offer shifts a piped ahoy install answer stream: the drain-rule category question and the offer itself are inserted mid-order, and the stdin prompter reads one line per question with no terminal gate, so in every adopter repository a scripted stream hands the answer meant for a later question (user-state, the identity pin) to the drain rule, can write an accepted drain eligibility record the script never asked for, and leaves the later question reading EOF. + +## Grounds + +- pursued: a piped stream of the pre-change answer count gets the same questions in the same order and writes no record; a drain-rule question put to a pipe would show it wrong From 5a0184ce88fe2098e40e6bb857e0b8c4d64b0519 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:26:58 +0100 Subject: [PATCH 29/69] chore(issues): capture reverify3-guardSet's two findings The colon forms of @ and * read as a single empty parameter does, a regression of the round-3 colon reading, and an IFS assigned through a name built from an expansion in a context the per-context reading never reached ($[ ], a subscript, an integer attribute, an increment, a substring offset, a value arithmetic evaluates). Refs: iss-2609300726419415 Refs: iss-2609300726507446 Refs: iss-2609300651127327 Refs: iss-2609300651115290 Assisted-by: Claude:claude-opus-5-5 --- ...f-w-w-w-and-is-taken-on-the-parameter-count.md | 15 +++++++++++++++ ...sure-of-iss-2609300651115290-s-class-an-ifs.md | 15 +++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609300726419415-the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count.md create mode 100644 .abcd/work/issues/open/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md diff --git a/.abcd/work/issues/open/iss-2609300726419415-the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count.md b/.abcd/work/issues/open/iss-2609300726419415-the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count.md new file mode 100644 index 000000000..a49f5e010 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300726419415-the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300726419415" +slug: "the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count" +severity: "major" +category: "bug" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +remedy: "Apply the colon forms' set reading (no empty value) only to a single positional or special parameter; for @ and * keep the value with its empty text, so ${@:-x}/ blocks and ${1:-dist}/ stays allowed. Grounds: the bash manual, Shell Parameter Expansion, and the four shells' printf output with set -- \"\" \"\"." +--- + +The colon test of ${@:-w}, ${*:-w}, ${@:=w} and ${@:?} is taken on the parameter count, not on a joined value, but the guard read them as a single empty parameter does (the empty value never printed), a regression from iss-2609300651127327. With set -- "" "" (a script called as clean.sh "" "") bash 3.2, /bin/sh, dash and bash 5.3 hand rm the home for rm -rf $HOME${@:-x} and $HOME${*:?}, and / or /* for rm -rf ${@:-x}/*, ${*:-x}/ and ${@:?}/; all were allowed. diff --git a/.abcd/work/issues/open/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md b/.abcd/work/issues/open/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md new file mode 100644 index 000000000..263a0c490 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300726507446" +slug: "structural-closure-of-iss-2609300651115290-s-class-an-ifs" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/payload.go" +remedy: "Read the IFS of a line as unknown (cap its unquoted default, alternative, trim and replacement words and unquoted HOME/PWD) when any layer's text holds the name IFS or a name built from an expansion, read lexically over the raw text whatever the context: an expansion touching a name byte or another expansion, an expansion an assignment operator follows, or an expansion standing as the operand of a builtin that assigns the names it is handed. Every reported form is one of these shapes whatever context holds it, so the rule is their superset; it replaces the per-context readings (namingCommands words, markedAssignment, arithmeticNamesIFS). Grounds: the bash manual (Shell Arithmetic evaluates a variable's value as an expression; subscripts, $[ ], (( )), for (( )), [[ -eq ]], substring offsets and integer attributes are arithmetic) and the shells' printf output." +--- + +Structural closure of iss-2609300651115290's class: an IFS assigned through a name built from an expansion still passed the guard in contexts the per-context reading never reached. I=I; : $[${I}FS=1], a[${I}FS=1]=x, : ${a[${I}FS=1]}, declare -i n; n=${I}FS=1, (( ${I}FS++ )), : ${X:${I}FS=1} and x=${I}FS=1; : $((x)) each set IFS in bash 3.2, /bin/sh and bash 5.3, which then hand rm "" and / for rm -rf ${U:-1/1}; all were allowed. Rounds 1 and 3 each listed the contexts that can set IFS and each re-verify found another, so the fix is one fail-closed rule, not another context. From ff4016a8413e7962d0027a3d9c70d907855bb1d8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:27:20 +0100 Subject: [PATCH 30/69] fix(guard): take the colon test of @ and * on the count, not a value ${@:-w}, ${*:-w}, ${@:=w} and ${@:?} test whether there are any positional parameters, not whether a joined value is empty: after set -- "" "" they print the two empty parameters, so ${@:-x}/ is / and $HOME${*:?} is the home in bash 3.2, /bin/sh, dash and bash 5.3. The colon reading that drops the empty value now applies to a single positional or special parameter only; ${1:-dist}/ stays allowed. Refs: iss-2609300726419415 Assisted-by: Claude:claude-opus-5-5 --- internal/core/guard/guardset_test.go | 12 +++++++++++- internal/core/guard/unknown.go | 10 +++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/internal/core/guard/guardset_test.go b/internal/core/guard/guardset_test.go index e9fa27a02..6aa447096 100644 --- a/internal/core/guard/guardset_test.go +++ b/internal/core/guard/guardset_test.go @@ -303,7 +303,6 @@ func TestColonDefaultsOfAnEmptyParameterTheWrittenCompareReads(t *testing.T) { {`rm -rf "${1:-build}"/*`, shellBare | shellSQ, VerdictAllow, ""}, {`rm -rf ${1:-dist}/`, shellBare | shellSQ, VerdictAllow, ""}, {`rm -rf "${1:-dist}/"*`, shellBare | shellSQ, VerdictAllow, ""}, - {`rm -rf ${@:-x}/`, shellBare | shellSQ, VerdictAllow, ""}, {`rm -rf ${1:=dist}/`, shellBare | shellSQ, VerdictAllow, ""}, {`rm -rf ${1:?}/`, shellBare | shellSQ, VerdictAllow, ""}, {`rm -rf ./${1:-dist}`, shellBare | shellSQ, VerdictAllow, ""}, @@ -318,6 +317,17 @@ func TestColonDefaultsOfAnEmptyParameterTheWrittenCompareReads(t *testing.T) { {`rm -rf ${1-dist}/`, all, VerdictBlock, home}, {`rm -rf ${1=dist}/`, all, VerdictBlock, home}, {`rm -rf ${@-x}/`, all, VerdictBlock, home}, + // `@` and `*` take the colon test on the parameter COUNT, not on a + // joined value: with `set -- "" ""` there are two parameters, so + // `${@:-x}` prints the two empty ones and `${@:?}` does not stop + // (reverify3-guardSet finding 1). + {`rm -rf ${@:-x}/`, all, VerdictBlock, home}, + {`rm -rf ${@:-x}/*`, all, VerdictBlock, home}, + {`rm -rf ${*:-x}/`, all, VerdictBlock, home}, + {`rm -rf ${@:?}/`, all, VerdictBlock, home}, + {`rm -rf ${*:=x}/`, all, VerdictBlock, home}, + {`rm -rf $HOME${@:-x}`, all, VerdictBlock, home}, + {`rm -rf $HOME${*:?}`, all, VerdictBlock, home}, {`rm -rf ${1?}/`, all, VerdictBlock, home}, // An indirection past a colon default still reads as every value. {`rm -rf ${!X:-dist}/`, all, VerdictBlock, home}, diff --git a/internal/core/guard/unknown.go b/internal/core/guard/unknown.go index d4087bb53..455ecefff 100644 --- a/internal/core/guard/unknown.go +++ b/internal/core/guard/unknown.go @@ -413,13 +413,21 @@ func spellParameterAt(body string, depth int, split bool) []string { same := "${" + name + "}" value := []string{same} // set is the value as the colon forms read it (`${1:-w}`, `${1:=w}`, - // `${1:?}`): an empty parameter counts as unset there, so the + // `${1:?}`): a single empty parameter counts as unset there, so the // expansion never prints the empty value (reverify-guardSet finding 2). set := value if !indirect && emptyable(name) { // `${!}`, `${@}`, `${1}` can print nothing (emptyable), and every // other operator reads that nothing as it reads a value. value = append([]string{same}, "") + if name == "@" || name == "*" { + // `@` and `*` take the colon test on the parameter count, not + // on a joined value: after `set -- "" ""`, `${@:-x}` prints + // the two empty parameters and `${*:?}` does not stop, so the + // colon forms read as the value too (reverify3-guardSet + // finding 1). + set = value + } } // orNothing is the value, or nothing: a subscript naming an element // that is not set (`${A[1]}` of a scalar), and a case change or a From 84ad332c59fb02fbf78c5d0d43d9847f09ea75b8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:37:18 +0100 Subject: [PATCH 31/69] fix(guard): read a name built from an expansion by its shape, not its context Rounds 1 and 3 read an IFS assigned through a built name context by context (a declaration's operand, an eval'd assignment, $(( )) and (( ))), and each re-verify found a context the reading never reached: $[ ], a subscript, an integer variable's value, an increment, a substring offset, a value an arithmetic reference evaluates. bash assigns a name in more places than can be listed, so the line's IFS is now read as unknown whenever the raw text of any layer holds the name IFS where no word holds it, or builds a name from an expansion: an expansion beside an I, F or S byte, one an assignment operator follows or ++ precedes, or one standing whole as the operand of a builtin that assigns the names it is handed. The rule reads the shape and never the context, so it is a superset of every reported form (each has I, F or S written beside the expansion, or is a whole operand), and it replaces arithmeticNamesIFS, the arithmetic flag's three call sites and markedAssignment. The brief's mechanism paragraph also gains the round's colon-count reading of $@ and $*. Refs: iss-2609300726507446 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/17-guard.md | 29 ++- commands/guard.md | 8 +- internal/core/guard/guardset_test.go | 58 ++++++ internal/core/guard/payload.go | 179 ++++++++++++++---- internal/core/guard/tokenize.go | 64 +++---- internal/core/guard/unknownsites_test.go | 1 + 6 files changed, 254 insertions(+), 85 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/17-guard.md b/.abcd/development/brief/04-surfaces/17-guard.md index 06f132b86..b1d2ac356 100644 --- a/.abcd/development/brief/04-surfaces/17-guard.md +++ b/.abcd/development/brief/04-surfaces/17-guard.md @@ -326,8 +326,10 @@ variables its text holds, and a name runs on into the letters a list or a sequence places after it (`{$HOME,x}`, `$HOME/{.*,}`, `$HO{ME,}`, `$HO{M..M}E`); an expansion whose operator can leave the value as it is reads as the variable itself — a default, an assignment or an error message -(`${HOME:-x}`), where the colon forms never print an empty value -(`${1:-dist}/` is `${1}/` or `dist/`) and an empty word prints the empty text +(`${HOME:-x}`), where the colon forms of a single parameter never print an +empty value (`${1:-dist}/` is `${1}/` or `dist/`), while those of `$@` and +`$*` test the parameter count and can (`${@:-x}/` is `/` after +`set -- "" ""`), and an empty word prints the empty text (`${X:-}/` is also `/`), a trim or a pattern replacement (`${HOME%/}`, `${HOME#x}`, `${HOME/x/y}`), any substring of a variable, which also reads as the root and as nothing (`${X:1}`, `${PWD:0:1}`), while a slice of the positional @@ -352,13 +354,23 @@ background, `$@`, `$*` and a positional one with no argument, `$_` after `"${1}"/` and `/$!` are `/`), and `$!` in a pattern as text of any length (`${PWD%%$!*}` is `${PWD%%*}`); a replacement's pattern is read both where bash 3.2 ends it and where bash 5 does, at a quoted `/` -(`${X/"/"*/$HOME}`); and on a line that names IFS — in any word, in a -declaration's, a `read`'s or a `printf -v`'s name or an assignment's name -built by an expansion (`export ${I}FS=x`, `eval "I${F:-F}S=x"`), or in an -arithmetic expression (`: $((IFS=1))`) — an unquoted default's or +(`${X/"/"*/$HOME}`); and on a line that names IFS — in any word or +anywhere in its text (`: $((IFS=1))`), or through a name built from an +expansion — an unquoted default's or alternative's word, and an unquoted home, reads as every target, since the fields bash splits it into rest on that IFS (`IFS=x; rm -rf ${U:-x/x}`, -`IFS=Uv; rm -rf $HOME/x`). A run of `/` written before the home names the +`IFS=Uv; rm -rf $HOME/x`). A name built from an expansion is read by its +shape in the raw text of each layer, never by its context, since bash +assigns one in a declaration's or a `read`'s operand, an eval'd +assignment and every arithmetic context (`$[ ]`, a subscript, a substring +offset, `[[ -eq ]]`, an integer variable's value, a value an arithmetic +reference evaluates): an expansion beside an `I`, `F` or `S` byte +(`export ${I}FS=x`, `a[${I}FS=1]=x`, `eval "I${F:-F}S=x"`), one an +assignment operator follows (`${N}=x`, `$x += 1`, `$x++`) or `++` comes +before, and one standing whole as a name a declaration, a `read` or a +`printf -v` assigns (`printf -v "$n" x`). That shape over-reads on the +refusing side: `IFS=x rm -rf ${U:-x/x}`, and `read -p "$prompt" f;` or +`mkdir ${V}S;` before `rm -rf ${U:-x/x}`. A run of `/` written before the home names the home (`/$HOME`). A trim that leaves the path above the home (`${HOME%/*}`) blocks as the home does. Each target is also compared as a path with its redundant separators taken out, since the kernel reads a run of @@ -421,7 +433,8 @@ table does not name; a REST path an entry names by its root segment when the host serves that API under a prefix; an IFS the shell already holds when the line starts, or gains during the line through a name the guard does not read (a sourced file, a nameref set before -the line), +the line, a name made of expansions alone such as `${a}${b}`, or the whole +value of a variable a command's output set, as in `x=$(cmd); : $((x))`), since every line is read from the default IFS; a pid list a kill reads through a variable or a file, or from a `ps | grep` chain; a payload inside a non-shell interpreter such as `python -c`, which is diff --git a/commands/guard.md b/commands/guard.md index 2840a6186..804070f5b 100644 --- a/commands/guard.md +++ b/commands/guard.md @@ -248,7 +248,9 @@ builds them: as the command in command position, as operands after it, and at every payload layer the guard follows, so a document whose own text is `$(cat <<'F' … F)` is read too. Text written in the word is not split, as bash does not split it, and an assignment's value is not split either. On a command -line where any other command names IFS (`IFS=x;`, `export IFS=x`), an unquoted +line where any other command names IFS (`IFS=x;`, `export IFS=x`, or a name built +from an expansion anywhere in the line's text, such as `export ${I}FS=x` or +`: $[${I}FS=1]`), an unquoted fixed output is a **block** (`ifs-split-unread`): the guard splits on the default IFS only, and refuses rather than work out which assignment reaches which expansion. A prefix assignment (`IFS=x $(…)`) does not reach its own command's @@ -390,7 +392,9 @@ runs, or `pkill` or `killall` as the program a variable names (`$P make`) — because reading each would refuse the ordinary commands a variable carries a value for, an IFS the shell already holds when the line starts or gains during the line through a name the guard does not read -(a sourced file, a nameref set before the line; every line is read from the default IFS), a hazard inside a non-shell interpreter's payload (`python -c`, +(a sourced file, a nameref set before the line, a name made of expansions +alone such as `${a}${b}`, the whole value of a variable a command's output set; +every line is read from the default IFS), a hazard inside a non-shell interpreter's payload (`python -c`, `perl -e`) — one opaque token the tokenizer cannot read, today a silent allow, not a warn (a warn for it is a recorded design target, not yet implemented), or a dangerous form no entry describes. Nor does an allow see what a lone substitution diff --git a/internal/core/guard/guardset_test.go b/internal/core/guard/guardset_test.go index 6aa447096..56c15ed85 100644 --- a/internal/core/guard/guardset_test.go +++ b/internal/core/guard/guardset_test.go @@ -289,6 +289,64 @@ func TestIFSNamedThroughAMarkTheWrittenCompareReads(t *testing.T) { }) } +// TestANameBuiltFromAnExpansionTheWrittenCompareReads — reverify3-guardSet +// finding 2, closed by one rule rather than one more context: a line that +// builds a name from an expansion anywhere (an expansion touching a name +// byte or another expansion, or standing where an assignment operator +// follows) reads its IFS as unknown. Every form sets IFS in bash 3.2, +// /bin/sh and bash 5.3, which then hand rm `""` and `/` for `${U:-1/1}`. +func TestANameBuiltFromAnExpansionTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + checkSpellingCases(t, []spellingCase{ + // The four forms the round-3 reading never reached. + {`I=I; : $[${I}FS=1]; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; a[${I}FS=1]=x; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; : ${a[${I}FS=1]}; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`declare -i n; I=I; n=${I}FS=1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + // Their siblings: an integer attribute by typeset, an increment, + // two expansions side by side, a substring offset, and a value an + // arithmetic reference evaluates. + {`typeset -i n; I=I; n=${I}FS=1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; (( ${I}FS++ )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`a=I; b=FS; export ${a}${b}=1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`X=abc; I=I; : ${X:${I}FS=1}; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; x=${I}FS=1; : $((x)); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + // A whole expansion that is the target of an assignment. + {`x=$(cat f); (( $x=1 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`x=$(cat f); (( ++$x )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`x=$(cat f); (( $x += 1 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + // An element of IFS leaves the split alone in bash, but the name is + // read all the same: an over-read on the fail-closed side. + {`I=I; eval "${I}FS[0]=x"; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + // The contexts the round-3 reading reached keep their verdict. + {`I=I; : $((x[${I}FS=1])); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; [[ 1 -eq ${I}FS=1 ]]; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; for (( ${I}FS=1; 0; )); do :; done; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; while (( ${I}FS=1 )); do break; done; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; let "${I}FS=1"; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; printf -v${I}FS x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; env ${I}FS=x true; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`f() { local ${I}FS=x; rm -rf ${U:-x/x}; }; f`, shellBare | shellSQ, VerdictBlock, home}, + {`eval "export I${F:-F}S=x"; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`I=I; rm -rf $HOME/x; export ${I}FS=U`, shellBare | shellSQ, VerdictBlock, home}, + // The look-alikes: a name the line writes, a plain subscript, a + // value the line spells, and an expansion beside text no name the + // shell splits on can be built from. + {`IFS= read -r f; rm -rf "$f"`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS=, read -ra arr <<< "$x"; rm -rf "${arr[0]}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`n=$((n+1)); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`a[$i]=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`export PATH=$HOME/bin:$PATH; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`mkdir -p build_${V}; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf $HOME/.cache/app_${V}`, shellBare | shellSQ, VerdictAllow, ""}, + {`(( $n == 1 )) && rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`: $(($n <= 1)); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`[ $a = b ] && rm -rf ${TMPDIR:-/tmp}/x`, shellBare | shellSQ, VerdictAllow, ""}, + {`git log --$fmt; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`rm -rf ${A:-x}${B:-$HOME}/x`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + // TestColonDefaultsOfAnEmptyParameterTheWrittenCompareReads — // reverify-guardSet finding 2. With the colon, a default, an assignment and // an error message treat an empty parameter as unset, so `${1:-dist}` with diff --git a/internal/core/guard/payload.go b/internal/core/guard/payload.go index f89707a68..c58b2aad8 100644 --- a/internal/core/guard/payload.go +++ b/internal/core/guard/payload.go @@ -492,25 +492,19 @@ func wordFeeds(s segment, keep func(int) bool) []feed { return append(rest, run) } -// namesIFS reports whether any word of segs names IFS, as splitAfterIFS -// reads a naming: an assignment in a command of its own, an `export`, a -// `read`, a prefix assignment, or any other word that holds the name. -// -// A name can also be built by an expansion the guard does not spell -// (reverify-guardSet finding 1): with I=I, `export ${I}FS=x`, -// `read -r ${I}FS`, `printf -v ${I}FS x` and `eval "I${F:-F}S=x"` each set -// IFS. So a word holding an expansion's mark counts where it can be a name -// a command assigns: any word after a command that assigns the names it is -// handed (namingCommands, read wherever it stands in the command), the -// word after `printf -v` or `wait -p`, and any assignment-shaped word whose -// name holds the mark (markedAssignment), which an eval or a shell handed -// the string runs as an assignment. That reading refuses on the side of a -// name: `read -p "$prompt" f` counts too. An arithmetic expression the -// tokenizer steps over names IFS through segment.arithmeticAssigns -// (`: $((IFS=1))`). +// namesIFS reports whether segs name IFS, as splitAfterIFS and +// capIFSSplits read a naming: any word that holds the name once its quotes +// are read (`IFS=x`, `declare "I"'FS=x'`, `declare $'\x49FS=x'`), a text +// the tokenizer read that holds the name or builds a name from an +// expansion (segment.namesIFSInText, buildsName), and an expansion +// standing whole as a name a builtin assigns (`printf -v "$n" x`, +// `read $v`, `declare $(cmd)=x`): namingCommands' operands, wherever they +// stand in the command, and the word after `printf -v` or `wait -p`. That +// last reading refuses on the side of a name: `read -p "$prompt" f` +// counts too. func namesIFS(segs []segment) bool { for _, s := range segs { - if s.arithmeticAssigns { + if s.namesIFSInText { return true } naming, target := false, false @@ -520,7 +514,7 @@ func namesIFS(segs []segment) bool { if strings.Contains(tok, "IFS") { return true } - if nameMarked(tok) && (naming || target || markedAssignment(tok) || flag != "" && strings.HasPrefix(tok, flag)) { + if nameMarked(tok) && (naming || target || flag != "" && strings.HasPrefix(tok, flag)) { return true } target = flag != "" && tok == flag @@ -557,25 +551,144 @@ func nameMarked(tok string) bool { return strings.IndexByte(tok, unknownMark) >= 0 || strings.IndexByte(tok, varMark) >= 0 } -// markedAssignment reports whether tok is shaped as an assignment -// (`NAME=value`, `NAME+=value`) whose name holds an expansion's mark: -// `I${F:-F}S=x` in the string an eval runs is `IFS=x` there. -func markedAssignment(tok string) bool { - eq := strings.IndexByte(tok, '=') - if eq <= 0 { - return false +// buildsName reports whether text builds a name from an expansion, read +// lexically over the raw text of one layer, quotes, arithmetic bodies, +// subscripts and here-documents included (reverify3-guardSet finding 2). +// bash assigns a name so built in more places than can be listed: a +// declaration's or a `read`'s operand, an eval'd assignment word, and every +// arithmetic context (`$(( ))`, `(( ))`, `$[ ]`, `for (( ))`, a subscript, +// a substring offset, `[[ -eq ]]`, an integer variable's value, and a +// variable's value an arithmetic reference evaluates). So the rule reads +// the shape and never the context: an expansion (`$name`, `${…}`, `$(…)`, +// `$[…]`, a backtick substitution, `$1`, `$@`, `$*`, or a mark a payload +// carries) that +// - touches an I, F or S byte: a name that is IFS and spans an +// expansion's edge has a byte of IFS written beside that edge, unless +// it is made of expansions alone; +// - stands as the target of an assignment: `=` follows it +// (`${N}=x`), a compound assignment operator follows it, spaced or not +// (`$x += 1`, `$x<<=1`), `++` or `--` follows it, or `++` comes +// before it. +// +// `$#`, `$?`, `$$`, `$!` and `$-` print numbers or option letters and are +// not read. What it does not read is a name whose text the line does not +// write: one made of expansions alone (`${a}${b}`), or the whole value of +// a variable a command's output set (`x=$(cmd); : $((x))`, `--$x`), which is +// the class of a sourced file's IFS, and a whole expansion a spaced `=` +// follows (`(( $x = 1 ))`), which reads as a test's comparison does. +func buildsName(text string) bool { + tally(len(text)) + type open struct { + closer byte + at int // the expansion's `$` or backtick; -1 for a bracket + } + var stack []open + var pending [4]int + slot := func(c byte) int { + return strings.IndexByte("})]`", c) + } + push := func(c byte, at int) { + stack = append(stack, open{c, at}) + pending[slot(c)]++ + } + // closeAt closes the innermost open c at i, and reports whether the + // expansion it closes builds a name. + closeAt := func(c byte, i int) bool { + if pending[slot(c)] == 0 { + return false + } + for { + top := stack[len(stack)-1] + stack = stack[:len(stack)-1] + pending[slot(top.closer)]-- + if top.closer == c { + return top.at >= 0 && nameAt(text, top.at, i+1) + } + } } - name := strings.TrimSuffix(tok[:eq], "+") - marked := false - for i := 0; i < len(name); i++ { - switch c := name[i]; { + for i := 0; i < len(text); i++ { + switch c := text[i]; { + case c == '\\': + i++ case c == unknownMark || c == varMark: - marked = true - case !isNameByte(c): - return false + if nameAt(text, i, i+1) { + return true + } + case c == '`': + if pending[slot('`')] > 0 { + if closeAt('`', i) { + return true + } + } else { + push('`', i) + } + case c == '$' && i+1 < len(text): + switch n := text[i+1]; { + case n == '{': + push('}', i) + i++ + case n == '(': + push(')', i) + i++ + case n == '[': + push(']', i) + i++ + case isNameStart(n): + j := i + 1 + for j < len(text) && isNameByte(text[j]) { + j++ + } + if nameAt(text, i, j) { + return true + } + i = j - 1 + case n >= '0' && n <= '9' || n == '@' || n == '*': + if nameAt(text, i, i+2) { + return true + } + i++ + } + case c == '{': + push('}', -1) + case c == '(': + push(')', -1) + case c == '}' || c == ')' || c == ']': + if closeAt(c, i) { + return true + } } } - return marked + return false +} + +// nameAt reports whether the expansion text[start:end] builds a name, by +// buildsName's shapes. +func nameAt(text string, start, end int) bool { + if start > 0 && strings.IndexByte("IFS", text[start-1]) >= 0 { + return true + } + if start >= 2 && text[start-2:start] == "++" { + return true + } + if end >= len(text) { + return false + } + rest := text[end:] + switch { + case strings.IndexByte("IFS", rest[0]) >= 0: + return true + case strings.HasPrefix(rest, "++") || strings.HasPrefix(rest, "--"): + return true + case rest[0] == '=': + // `=` counts only against the expansion: spaced, it is also a + // test's comparison (`[ $a = b ]`). + return !strings.HasPrefix(rest, "==") + } + rest = strings.TrimLeft(rest, " \t") + if strings.HasPrefix(rest, "<<=") || strings.HasPrefix(rest, ">>=") { + return true + } + return len(rest) >= 2 && strings.IndexByte("+-*/%&|^", rest[0]) >= 0 && rest[1] == '=' } // capIFSSplits reads each word of segs whose fields rest on the default IFS diff --git a/internal/core/guard/tokenize.go b/internal/core/guard/tokenize.go index 4d9f48ecc..4eef8b838 100644 --- a/internal/core/guard/tokenize.go +++ b/internal/core/guard/tokenize.go @@ -92,12 +92,12 @@ type segment struct { // the token, where the variable is the unknown word's mark // (iss-2609290321312087). nil when no word holds a variable. spelled map[int][]string - // arithmeticAssigns records that the line holds an arithmetic - // expression that can assign IFS (arithmeticNamesIFS): `: $((IFS=1))` - // sets it, and the expression leaves no word to read the name in. It - // rides on an empty segment of its own, as substitutionUnread does, and - // namesIFS reads it. - arithmeticAssigns bool + // namesIFSInText records that the text a tokenize call read holds the + // name IFS or builds a name from an expansion (buildsName), wherever it + // stands: an arithmetic body or a subscript leaves no word to read it + // in. It rides on an empty segment of its own, as substitutionUnread + // does, and namesIFS reads it. + namesIFSInText bool // ifsSplit records, per token index, a spelled word whose fields rest on // the default IFS (ifsSplits in unknown.go), which a line that names IFS // reads as past its bound (capIFSSplits). nil when no word is. @@ -550,9 +550,6 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // could not read, on an empty segment of its own. Once per call is enough: // the verdict is the whole command's. unreadRaised := false - // arithIFS records an arithmetic body that can assign IFS - // (segment.arithmeticAssigns), raised once, when the input ends. - arithIFS := false unread := func() { if !unreadRaised { unreadRaised = true @@ -841,7 +838,6 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // substitution inside it runs, so each one is followed. arithmetic := func(body string) { tally(len(body)) - arithIFS = arithIFS || arithmeticNamesIFS(body) for j := 0; j < len(body); { switch { case body[j] == '\\': @@ -1519,7 +1515,6 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { i++ break } - arithIFS = arithIFS || arithmeticNamesIFS(line[i+3:end-1]) openSubstitution(parenArithExp, i, false) parens[len(parens)-1].end = end lastList = false @@ -1604,7 +1599,6 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { unread() } if end >= 0 { - arithIFS = arithIFS || arithmeticNamesIFS(line[i+2:end-1]) openSubstitution(parenArithExp, i, false) parens[len(parens)-1].end = end parens[len(parens)-1].bare = true @@ -1764,42 +1758,28 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { if len(pending) > 0 { markHeredocUnterminated(&segs, chain) } - if arithIFS { - segs = append(segs, segment{chain: chain, arithmeticAssigns: true}) + if depth == 0 && (unwordedIFS(line, segs) || buildsName(line)) { + // The text of every substitution depth is in the line read at + // depth 0, so it is read once, there. + segs = append(segs, segment{chain: chain, namesIFSInText: true}) } return segs, nil } -// arithmeticNamesIFS reports whether an arithmetic expression's body can -// assign IFS: it names IFS, or it assigns (`=`, `+=`, `<<=`, never `==`, -// `!=`, `<=` or `>=`) and holds an expansion that can build the name -// (`$((${I}FS=1))`). bash 3.2, /bin/sh, dash and bash 5.3 split -// `${U:-1/1}` into `""` and `/` after `: $((IFS=1))`. -func arithmeticNamesIFS(body string) bool { - tally(len(body)) - if strings.Contains(body, "IFS") { - return true - } - if !strings.ContainsAny(body, "$`") { - return false - } - for i := 0; i < len(body); i++ { - if body[i] != '=' { - continue - } - if i+1 < len(body) && body[i+1] == '=' { - i++ - continue - } - if i == 0 || strings.IndexByte("=!<>", body[i-1]) < 0 { - return true - } - if i >= 2 && body[i-2] == body[i-1] && body[i-1] != '=' && body[i-1] != '!' { - // `<<=` and `>>=` assign. - return true +// unwordedIFS reports whether line holds the name IFS more times than the +// words segs read from it do: an arithmetic body (`: $((IFS=1))`), a +// subscript or an offset inside a parameter expansion leave no word to +// read it in. A name the words hold is read there (namesIFS), where a +// prefix assignment does not reach its own command's substitution +// (splitAfterIFS). +func unwordedIFS(line string, segs []segment) bool { + n := strings.Count(line, "IFS") + for _, s := range segs { + for _, tok := range s.tokens { + n -= strings.Count(tok, "IFS") } } - return false + return n > 0 } // arithmeticOperand is the word an arithmetic expansion leaves in its diff --git a/internal/core/guard/unknownsites_test.go b/internal/core/guard/unknownsites_test.go index 795c226b8..aec7272c7 100644 --- a/internal/core/guard/unknownsites_test.go +++ b/internal/core/guard/unknownsites_test.go @@ -56,6 +56,7 @@ var wordReaders = map[string]string{ "splitStringValue": "commandArrivals and nameCouldBe", "scanEnvSplits": "readWord, flagCouldBe and clusterCouldCarry on every unknown word", "launcherPayloads": "readWord on every word", + "nameAt": "exempt: reads the raw text around an expansion for an assignment operator (`++`, `-=`), never a command word or a flag", "guessedEvalPayload": "exempt: reads eval's literal `--`; vanishable drops a word that may print nothing", "evalPayload": "exempt: reads eval's literal `--`, which no substitution spells (the rule's terminator clause)", "shellCPayloads": "clusterCouldCarry on every word", From 74595edffb05c0c46c02aef72a58fb7115b44da2 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:37:44 +0100 Subject: [PATCH 32/69] chore: resolve reverify3-guardSet's two findings The colon forms of @ and * read on the parameter count (ff4016a84) and the name built from an expansion read by its shape (84ad332c5), each with impact fix. The second record's remedy is rewritten to the rule as built. Resolves: iss-2609300726419415 Resolves: iss-2609300726507446 Assisted-by: Claude:claude-opus-5-5 --- ...-of-iss-2609300651115290-s-class-an-ifs.md | 15 ------------ ...w-w-and-is-taken-on-the-parameter-count.md | 8 +++++++ ...-of-iss-2609300651115290-s-class-an-ifs.md | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+), 15 deletions(-) delete mode 100644 .abcd/work/issues/open/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md rename .abcd/work/issues/{open => resolved}/iss-2609300726419415-the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count.md (69%) create mode 100644 .abcd/work/issues/resolved/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md diff --git a/.abcd/work/issues/open/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md b/.abcd/work/issues/open/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md deleted file mode 100644 index 263a0c490..000000000 --- a/.abcd/work/issues/open/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -schema_version: 1 -id: "iss-2609300726507446" -slug: "structural-closure-of-iss-2609300651115290-s-class-an-ifs" -severity: "major" -category: "security" -source: "review-followup" -found_during: "autonomous run 2026-09-23" -origin: researcher-authored -production_mode: hand-written -found_at: "internal/core/guard/payload.go" -remedy: "Read the IFS of a line as unknown (cap its unquoted default, alternative, trim and replacement words and unquoted HOME/PWD) when any layer's text holds the name IFS or a name built from an expansion, read lexically over the raw text whatever the context: an expansion touching a name byte or another expansion, an expansion an assignment operator follows, or an expansion standing as the operand of a builtin that assigns the names it is handed. Every reported form is one of these shapes whatever context holds it, so the rule is their superset; it replaces the per-context readings (namingCommands words, markedAssignment, arithmeticNamesIFS). Grounds: the bash manual (Shell Arithmetic evaluates a variable's value as an expression; subscripts, $[ ], (( )), for (( )), [[ -eq ]], substring offsets and integer attributes are arithmetic) and the shells' printf output." ---- - -Structural closure of iss-2609300651115290's class: an IFS assigned through a name built from an expansion still passed the guard in contexts the per-context reading never reached. I=I; : $[${I}FS=1], a[${I}FS=1]=x, : ${a[${I}FS=1]}, declare -i n; n=${I}FS=1, (( ${I}FS++ )), : ${X:${I}FS=1} and x=${I}FS=1; : $((x)) each set IFS in bash 3.2, /bin/sh and bash 5.3, which then hand rm "" and / for rm -rf ${U:-1/1}; all were allowed. Rounds 1 and 3 each listed the contexts that can set IFS and each re-verify found another, so the fix is one fail-closed rule, not another context. diff --git a/.abcd/work/issues/open/iss-2609300726419415-the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count.md b/.abcd/work/issues/resolved/iss-2609300726419415-the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count.md similarity index 69% rename from .abcd/work/issues/open/iss-2609300726419415-the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count.md rename to .abcd/work/issues/resolved/iss-2609300726419415-the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count.md index a49f5e010..d1b52bb7b 100644 --- a/.abcd/work/issues/open/iss-2609300726419415-the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count.md +++ b/.abcd/work/issues/resolved/iss-2609300726419415-the-colon-test-of-w-w-w-and-is-taken-on-the-parameter-count.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/unknown.go" remedy: "Apply the colon forms' set reading (no empty value) only to a single positional or special parameter; for @ and * keep the value with its empty text, so ${@:-x}/ blocks and ${1:-dist}/ stays allowed. Grounds: the bash manual, Shell Parameter Expansion, and the four shells' printf output with set -- \"\" \"\"." +resolution: "The colon forms of @ and * keep the empty value, since their colon test is on the parameter count; the set reading applies to a single positional or special parameter only." +impact: fix +resolved_by: + commit: "ff4016a84" --- The colon test of ${@:-w}, ${*:-w}, ${@:=w} and ${@:?} is taken on the parameter count, not on a joined value, but the guard read them as a single empty parameter does (the empty value never printed), a regression from iss-2609300651127327. With set -- "" "" (a script called as clean.sh "" "") bash 3.2, /bin/sh, dash and bash 5.3 hand rm the home for rm -rf $HOME${@:-x} and $HOME${*:?}, and / or /* for rm -rf ${@:-x}/*, ${*:-x}/ and ${@:?}/; all were allowed. + +## Grounds + +- pursued: ${@:-x}/, ${@:-x}/*, ${*:-x}/, ${@:?}/, ${*:=x}/, $HOME${@:-x} and $HOME${*:?} block bare, in bash -c and in sh -c, while ${1:-dist}/ stays allowed; a shell that printed dist/ for ${@:-x}/ after set -- "" "" would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md b/.abcd/work/issues/resolved/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md new file mode 100644 index 000000000..17ff4ba16 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609300726507446-structural-closure-of-iss-2609300651115290-s-class-an-ifs.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609300726507446" +slug: "structural-closure-of-iss-2609300651115290-s-class-an-ifs" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/payload.go" +remedy: "Read the IFS of a line as unknown (cap its unquoted default, alternative, trim and replacement words and unquoted HOME/PWD) when any layer's text holds the name IFS or a name built from an expansion, read lexically over the raw text whatever the context: an expansion beside an I, F or S byte, an expansion an assignment operator follows or ++ precedes, or an expansion standing whole as the operand of a builtin that assigns the names it is handed. Every reported form is one of these shapes whatever context holds it, so the rule is their superset; it replaces the per-context readings (markedAssignment, arithmeticNamesIFS and its three call sites). Grounds: the bash manual (Shell Arithmetic evaluates a variable's value as an expression; subscripts, $[ ], (( )), for (( )), [[ -eq ]], substring offsets and integer attributes are arithmetic) and the shells' printf output." +resolution: "A line's IFS reads as unknown when the raw text of any layer builds a name from an expansion (beside an I, F or S byte, an assignment target, or a whole assigning-builtin operand), whatever the context; the per-context readings are removed." +impact: fix +resolved_by: + commit: "84ad332c5" +--- + +Structural closure of iss-2609300651115290's class: an IFS assigned through a name built from an expansion still passed the guard in contexts the per-context reading never reached. I=I; : $[${I}FS=1], a[${I}FS=1]=x, : ${a[${I}FS=1]}, declare -i n; n=${I}FS=1, (( ${I}FS++ )), : ${X:${I}FS=1} and x=${I}FS=1; : $((x)) each set IFS in bash 3.2, /bin/sh and bash 5.3, which then hand rm "" and / for rm -rf ${U:-1/1}; all were allowed. Rounds 1 and 3 each listed the contexts that can set IFS and each re-verify found another, so the fix is one fail-closed rule, not another context. + +## Grounds + +- pursued: every re-verify form of all three rounds blocks, the testdata and fix corpus (1415 lines) changes only the probe line ${@:-x}/, and IFS= read -r f, n=$((n+1)), a[$i]=x and export PATH=$HOME/bin:$PATH stay allowed; a context that assigns a name built with I, F or S written beside an expansion and still allows would show it wrong From b993017e86625c16f78ae844f78210c695bd08bf Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:40:22 +0100 Subject: [PATCH 33/69] test(termsafe): allowlist buildsName's backtick scans buildsName reads a shell line's raw text, where a backtick opens or closes a command substitution; nothing is paired as a code span, so the file is allowlisted with its count of four. Refs: iss-2609300726507446 Assisted-by: Claude:claude-opus-5-5 --- internal/termsafe/codespan_canonical_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/termsafe/codespan_canonical_test.go b/internal/termsafe/codespan_canonical_test.go index 3d226d676..a339acdd6 100644 --- a/internal/termsafe/codespan_canonical_test.go +++ b/internal/termsafe/codespan_canonical_test.go @@ -32,6 +32,7 @@ var backtickScanners = map[string]backtickScanner{ "internal/adapter/scanner/identity.go": {1, "a delimiter set: a backtick is one of the characters that may end an identity token; nothing is paired"}, "internal/core/capture/promote.go": {1, "a WRITER: codeSpan measures the longest backtick run to choose a fence the value cannot close; nothing is paired"}, "internal/core/guard/tokenize.go": {23, "the shell tokenizer: a backtick there is command substitution, a shell grammar, not markdown"}, + "internal/core/guard/payload.go": {4, "buildsName reads a shell line's raw text for a name an expansion builds: a backtick there opens or closes a command substitution, a shell grammar, not markdown; nothing is paired"}, "internal/core/guard/unknown.go": {2, "spellWord spells a default's or an alternative's shell word, and readPattern reads a trim's or a replacement's pattern: a backtick in either opens a command substitution, whose output the spelling drops or the pattern reads as unknown text; nothing is paired"}, "internal/core/history/reconstruct_render.go": {1, "a WRITER: longestBacktickRun sizes a fence longer than any run in the body; nothing is paired"}, "internal/core/ideate/render.go": {1, "blockText asks whether a value opens with a backtick, then asks OpensBalancedCodeSpan, which pairs through PairCodeSpan"}, From 37bf44ed92549e9cb2a629ec0c5c9c2f007bbf6f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:52:27 +0100 Subject: [PATCH 34/69] chore(issues): capture that rulings CF1 and CF2 are not built The person ruled on 2026-09-30 that a blocker replaced by an accepted ADR, and a blocker reclassified as a discipline, both count as settled. The blocked pre-start check at this base still refuses both shapes, so the rulings are not "as built" and need a small code lane; this records-only lane files the change with its remedy rather than building it. Refs: iss-2609300751191426 Assisted-by: Claude:claude-opus-5-5 --- ...cf2-of-2026-09-30-are-not-built-the-blocked.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609300751191426-rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md diff --git a/.abcd/work/issues/open/iss-2609300751191426-rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md b/.abcd/work/issues/open/iss-2609300751191426-rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md new file mode 100644 index 000000000..b0cf1727b --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300751191426-rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300751191426" +slug: "rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked" +severity: "minor" +category: "drift" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25 (lane recRulingsDR, 2026-09-30)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/startcheck.go" +remedy: "In internal/core/intent/startcheck.go, end a supersession chain at an ADR successor by reading that ADR's status: status accepted settles the edge (the row passes and names the chain, the ADR marked accepted), while any other status, or an ADR this checkout does not hold, still refuses and names it; and let startBlockedRow treat a final record in disciplines/ as settled beside shipped/. Update the function comments, brief 04-surfaces/34-build.md and commands/build.md in the same change. Prove it by turning the 'adr successor' case of TestStartBlockedRowFollowsASupersededBlockerToItsReplacement into a pass for an accepted ADR and a refusal for a proposed one, plus a discipline-successor case (grounds: the rulings themselves; the fix depends on no outside practice)." +--- + +Rulings CF1 and CF2 of 2026-09-30 are not built: the blocked pre-start check still refuses a blocker whose supersession chain ends at an accepted ADR, and a blocker reclassified as a discipline. The person ruled, through the run's interviewer on 2026-09-30: 'CF1 blocker replaced by an accepted ADR: COUNTS AS SETTLED (the waiting intent may start)' and 'CF2 blocker reclassified as a discipline: COUNTS AS SETTLED (the waiting intent may start)'. At base 85d0bb8eb, followBlocker in internal/core/intent/startcheck.go stops an ADR successor with the problem 'adr-N is not an intent: a decision replaced the blocker, and nothing on the record says that settles the edge', and startBlockedRow counts only a final record in shipped/ as settled, so a chain ending in disciplines/ blocks its dependants for ever. Lane supersededBlocker asked both questions rather than building them; no live edge hits either shape today (itd-72 is superseded by adr-37 and blocks nothing), so nothing is refused wrongly yet. From a910d94d9f33d4485f3c7e90ddcf99af9c8574f8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:52:33 +0100 Subject: [PATCH 35/69] docs(decisions): record the person's eighteen rulings of 2026-09-30 One entry restating CF1, CF2, CG1, CI1, CI2, CJ1, CK1, CL1, CM1, DR1, DR2, DR4, DR5, DR6, DQ1a, DQ1b, DQ2 and DQ3, grouped as settled blockers, as built, build and keep, because the answers file is local-tier and uncommitted. The three rulings of 2026-09-29 the run still listed as owed (the ceilings of five and eight, and the remedies ruling) already have entries, so none is added twice. CF1 and CF2 were relayed as built; the pre-start check at this base still refuses both shapes, so the entry says so and names the capture. Refs: iss-2609300751191426, iss-2608290820473197, iss-2608290822140563 Refs: iss-2609281911024185, iss-2609281911024838 Assisted-by: Claude:claude-opus-5-5 --- .abcd/work/DECISIONS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 25682bb00..cbb1a1024 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2600,3 +2600,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-29 — The 2026-09-29 entry applying ruling J15 names one plain-path consequence of the ledger forgetting a stopped domain, a domain edited out of `rules.json` and back; there is a second, which needs no edit at all: a dormant domain activated with `*NAME` is in force for that prompt alone, so a prompt without the prefix drops it from the active set and the next `*NAME` renders it again (review-routerRemoval finding 2; recorded by fix round fix-routerRemoval of autonomous run A; Refs: iss-2608261550580260). Both follow from J15, since the domain left the set in between, and both are documented in brief `05-internals/03-configuration.md` "The prompt router's output"; the rule-loader text in `AGENTS.md` and the managed-repository marker block qualify "never re-injects unchanged rules" to a domain that stays in force. The earlier entry stands as written. - 2026-09-29 — Every new issue carries a remedy, and abcd's own automatic filers write one machine value when they have no fix (the product thinker's rulings BX3 and H12 of 2026-09-29, applied by lane remedyRequired of autonomous run A; partial of itd-82, whose decision 6 already required the field). BX3, verbatim: "REFUSE the filing; every new issue must carry remedy:". H12, verbatim: "'NO FIX YET' ALLOWED: automatic filers may write remedy 'none (filed automatically)'; the record is filed, drain skips it until a person writes a real remedy." As built: `capture` refuses a new issue with no remedy or a blank one, exit 2 and nothing written, naming `--remedy` and the machine value; the value is spelt once, `issueschema.MachineRemedy`, and written by every in-binary filer (the consistency pass, and an inbox report promoted without a remedy of its own, whose own remedy otherwise becomes the issue's); `abcd drain --dry-run` lists a record carrying it as ineligible, naming the automatic filer and the verb that answers it; and `capture remedy <iss-N> "<fix>"` writes or replaces the remedy on an open issue. A record filed before the rule carries none, stays readable and valid, and is listed as ineligible. The one-line capture friction that `commands/capture.md` and the principle `adversarial-review-scales-with-blast-radius` promised is tightened by the ruling, and both now say so. A person typing the machine value is REFUSED, by `capture --remedy` and by `capture remedy`, compared trimmed and case-folded: the value is useful only while it means that a machine filed the record, and a person has either a fix to name or the choice not to file; allowing it would let a hand-filed record pass as machine-filed and be skipped silently. A remedy chosen in an autonomous run cites its grounds, a prior-art or state-of-the-art check where the fix depends on outside practice (principle `prefer-sota`), in the capture's text; nothing checks that mechanically yet. - 2026-09-30 — Pending the person's ruling CL1, a promoted inbox report files the machine value: outside text never becomes a drain-eligible remedy without a person naming it (fix round of lane remedyRequired, autonomous run A, closing the review's trust finding on the entry of 2026-09-29 above). As built: `inbox promote` always writes `remedy: none (filed automatically)` (`issueschema.MachineRemedy`), whatever the report proposes, so `abcd drain --dry-run` lists the issue as ineligible; the sender's proposal stays in the issue's text under "Remedy the reporter proposes:", scrubbed like every other value the report carries, for a person to adopt with `capture remedy`. This narrows the entry above, which filed a report's own remedy as the issue's; the ruling CL1 may widen it again. +- 2026-09-30 — The person answered eighteen rulings of autonomous run A on 2026-09-30 (CF1, CF2, CG1, CI1, CI2, CJ1, CK1, CL1, CM1, DR1, DR2, DR4, DR5, DR6, DQ1a, DQ1b, DQ2, DQ3), relayed by the run's interviewer abcd-23 [b1e81b] and recorded by lane recRulingsDR for orchestrator abcd-8d [5bfdfb]. The answers are kept in the local-tier file `.abcd/.work.local/scratch/reports/rulings-answered-2609-29-b.md`, lines 13 to 30, and are restated here because that file is not committed. SETTLED BLOCKERS: a blocker replaced by an accepted ADR counts as settled, so the waiting intent may start (CF1); a blocker reclassified as a discipline counts as settled too (CF2). Neither is built at this entry's base: the blocked pre-start check still refuses both shapes, and iss-2609300751191426 carries the change. AS BUILT: the App token for the dependency re-authoring workflow is minted in the shell, an openssl-signed JWT exchanged with curl and revoked on exit, with no new action (CG1); when the prompt router cannot evaluate a prompt, its envelope carries the error and leaves the active-domain list out, so the client keeps what it had (CI1); a domain that returns to the active set is re-injected on every host, one that appends to a transcript included, not only on a client that snapshots (CI2); `abcd update` shows progress only when stderr is a terminal, and saved output stays clean (DR4). BUILD: the installer (`hooks/bootstrap.sh`) writes the previous release tag into binary-meta when it swaps a binary, the session hook stays read-only, and that tag replaces the setup_version comparison of the itd-111 update notice (CJ1); the SHELL domain also teaches a repository's own guard entries, generated the same way as the bundled registry's (CK1); `step` in `abcd implement check` is renamed `stage` too, in the same breaking v0.12.0 (CM1); the fix rounds a run spends before it hands a lane back are a per-run setting beside `--pace`, for example `--fix-rounds`, default 3 (DR1); the dependency-bump intent itd-2609221842494980 ships now, with the first live bump recorded as owed until the person has created the App and its secrets (DR2); only a self-contained agent is sent to a paid provider by default, so the cold-reading positions may go while a file-reading agent such as the intent auditor is refused with the reason, and a central override, a machine-level settings list the person keeps that no agent or repository can change, may name providers that take bundled-context requests for file-reading agents (DR5); a run works in parallel, building several pieces and running reviewers at once up to its agent limit, which is new build-loop work that makes the pacing intent's criterion 6 testable (DR6); an undecided fidelity audit reopens the work, sending the feature back to a lane, and never closes like a pass (DQ1a, iss-2608290820473197); the audit moves before the merge, as part of the build loop's last lane (per ruling AI), and until that loop is ready it stays after the merge and reopens on an undecided or failed verdict (DQ1b, iss-2608290822140563); the filing-time duplicate check covers every route, the promoted inbox report, the consistency pass and the reading ingest, in a lane after remedyRequired (DQ2, iss-2609281911024185); `abcd ahoy` offers to install gh on an explicit yes (DQ3, iss-2609281911024838). KEEP: a promoted inbox report's own remedy is kept but not usable: the sender's suggestion stays in the issue's text for reference, the remedy field is `none (filed automatically)`, and a drain skips the record until a person writes the fix, so the conservative default recorded above on 2026-09-30 stands and is not widened (CL1). The person added to CL1 that when a person comes to write the real fix for such an issue, the agent offers a state-of-the-art research pass first (principle `prefer-sota`), which `commands/capture.md` says under the `capture remedy` verb. The three rulings of 2026-09-29 owed to this ledger by earlier sessions of the run, the ceilings of five and of eight sub-agents and the remedies ruling, already have their entries above, so this entry adds none. From 7ce8b184d1b1f99b536bae2136af3c3fb8e33994 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:52:38 +0100 Subject: [PATCH 36/69] docs(capture): offer a SOTA pass before a person writes a machine-filed fix Ruling CL1 of 2026-09-30 keeps a promoted inbox report's remedy as the machine value and adds the person's note: when a person comes to write the real fix for such a record, the agent offers a state-of-the-art research pass first. The capture remedy section says so in one line. The verb's CLI help carries no prose beyond its one-line summary, so it is unchanged. Assisted-by: Claude:claude-opus-5-5 --- commands/capture.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/commands/capture.md b/commands/capture.md index 96363b10a..b8cad1ad5 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -504,6 +504,9 @@ replacement is never silent. Report `redacted` whenever it is non-zero. The record stays in `open/`. Refused with exit 2 and nothing written: an empty text, `none (filed automatically)` in any case (it would leave the record as the drain already skips it), a malformed or unknown id, and a record that is not open. +When a person comes to write the real fix for a record filed with `none (filed +automatically)`, offer to run a state-of-the-art research pass first (principle +`prefer-sota`) before they write it, and let them decline. ## Answer a reading item From 3674db3a42f59c8a1f2ac72282b2ec472d1ac076 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:55:01 +0100 Subject: [PATCH 37/69] fix(history): name the foreign owner when refusing another account's store Ruling CB1 keeps the refusal of every history verb, reads included, over a records leaf another account owns. Only the wording changes: the refusal named a refused write even on history list and show, which write nothing. It now names the owning uid, and the account name where the account database resolves it, and says the store belongs to another account. A leaf whose owner cannot be read keeps its refusal, worded for both reads and writes. Refs: iss-2609291731336469 Assisted-by: Claude:claude-opus-5-5 --- internal/core/history/location.go | 36 ++++++++++- .../core/history/records_leaf_mode_test.go | 64 ++++++++++++++++++- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/internal/core/history/location.go b/internal/core/history/location.go index c2860ab64..2df625e81 100644 --- a/internal/core/history/location.go +++ b/internal/core/history/location.go @@ -47,7 +47,9 @@ import ( "fmt" "io" "os" + "os/user" "path/filepath" + "strconv" "strings" "syscall" "time" @@ -219,8 +221,12 @@ func narrowRecordsLeaf(dir string) (string, error) { if named, err := os.Lstat(dir); err != nil || !os.SameFile(held, named) { return "", storeDirFault(dir, fsutil.ErrNotRealDir) } - if uid, ok := recordsLeafOwner(held); !ok || uid != uint32(os.Geteuid()) { - return "", &StorePathError{Path: dir, Msg: "the records directory is not owned by this account; refusing to write transcripts into it"} + uid, ok := recordsLeafOwner(held) + if !ok { + return "", &StorePathError{Path: dir, Msg: "the owner of the records directory cannot be read; refusing to read or write transcripts in it"} + } + if uid != uint32(os.Geteuid()) { + return "", &StorePathError{Path: dir, Msg: foreignOwnerRefusal(uid)} } perm := held.Mode().Perm() if perm&0o077 == 0 { @@ -249,6 +255,32 @@ var recordsLeafOwner = func(fi os.FileInfo) (uint32, bool) { return st.Uid, true } +// foreignOwnerRefusal words the refusal of a records leaf another account +// owns. Every verb refuses there, reads included (ruling CB1): the leaf's owner +// can plant or rewrite records a later session reads back as context, so the +// store neither reads nor writes it. The wording names that owner and says the +// store is another account's, because a read verb that reported a refused write +// would describe an act it never attempted (iss-2609291731336469). +func foreignOwnerRefusal(uid uint32) string { + owner := fmt.Sprintf("uid %d", uid) + if name, ok := recordsLeafOwnerName(uid); ok { + owner = fmt.Sprintf("%s (uid %d)", name, uid) + } + return "the records directory is owned by another account, " + owner + ", not by this one; refusing to use a transcript store another account controls" +} + +// recordsLeafOwnerName resolves the account name of the uid that owns a +// foreign records leaf, reporting false when the platform's account database +// does not know it (a container's bind mount often carries a uid with no entry). +// It is a var so a test can name an account without one existing. +var recordsLeafOwnerName = func(uid uint32) (string, bool) { + u, err := user.LookupId(strconv.FormatUint(uint64(uid), 10)) + if err != nil || u.Username == "" { + return "", false + } + return u.Username, true +} + // recordPerm is the mode every record is written with, for the same reason as // storeDirPerm: owner-only, so a record stays private even in a chain level an // earlier binary created wider (iss-2609012029343438). Capture writes a new diff --git a/internal/core/history/records_leaf_mode_test.go b/internal/core/history/records_leaf_mode_test.go index 8a81bd638..18f7b1d93 100644 --- a/internal/core/history/records_leaf_mode_test.go +++ b/internal/core/history/records_leaf_mode_test.go @@ -2,8 +2,10 @@ package history import ( "errors" + "fmt" "os" "path/filepath" + "strings" "testing" ) @@ -83,7 +85,7 @@ func TestResolveKeepsTheOwnerBitsOfTheRecordsLeaf(t *testing.T) { } // TestResolveRefusesARecordsLeafAnotherAccountOwns: a leaf this account does not -// own is never changed, and the store refuses to write into it, because its +// own is never changed, and the store refuses to use it, because its // owner can read it whatever its mode. The owner lookup is stubbed; the test // never needs a second account. func TestResolveRefusesARecordsLeafAnotherAccountOwns(t *testing.T) { @@ -141,3 +143,63 @@ func TestNarrowRecordsLeafNeverFollowsASymlink(t *testing.T) { t.Errorf("the symlink's target is mode %#o, want 0o755: the narrowing followed the link", got) } } + +// TestForeignOwnedRecordsLeafRefusalNamesTheOwner: ruling CB1 keeps the refusal +// of every verb, reads included, over a records leaf another account owns, and +// rules its wording: it names the foreign owner (the uid, and the account name +// where it resolves) and says the store belongs to another account, never that +// the store refuses to write, because history list and show write nothing +// (iss-2609291731336469). Both lookups are stubbed; no second account is needed. +func TestForeignOwnedRecordsLeafRefusalNamesTheOwner(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + chain := wideLegacyLayout(t, home) + records := chain[len(chain)-1] + foreign := uint32(os.Geteuid()) + 1 + + restoreOwner, restoreName := recordsLeafOwner, recordsLeafOwnerName + defer func() { recordsLeafOwner, recordsLeafOwnerName = restoreOwner, restoreName }() + recordsLeafOwner = func(os.FileInfo) (uint32, bool) { return foreign, true } + + verbs := map[string]func() error{ + "list": func() error { _, err := List(t.TempDir(), testRootSHA); return err }, + "show": func() error { _, _, err := Read(t.TempDir(), testRootSHA, "any"); return err }, + "capture": func() error { + _, err := Capture(t.TempDir(), testRootSHA, []byte("assistant: hi\n"), CaptureMeta{SessionID: "sess-foreign", Kind: "native"}) + return err + }, + } + uidWord := fmt.Sprintf("uid %d", foreign) + for _, tc := range []struct { + label string + name string + resolves bool + }{ + {"name resolves", "someone-else", true}, + {"name does not resolve", "", false}, + } { + recordsLeafOwnerName = func(uid uint32) (string, bool) { + if uid != foreign { + t.Errorf("owner name looked up for uid %d, want %d", uid, foreign) + } + return tc.name, tc.resolves + } + for verb, run := range verbs { + err := run() + var spe *StorePathError + if !errors.As(err, &spe) || spe.Path != records { + t.Fatalf("%s (%s): got %v, want a *StorePathError naming %s", verb, tc.label, err, records) + } + msg := spe.Msg + if !strings.Contains(msg, "owned by another account") || !strings.Contains(msg, uidWord) { + t.Errorf("%s (%s): %q must say the store is owned by another account and name %q", verb, tc.label, msg, uidWord) + } + if tc.resolves && !strings.Contains(msg, tc.name) { + t.Errorf("%s (%s): %q must name the owning account %q", verb, tc.label, msg, tc.name) + } + if strings.Contains(msg, "write") { + t.Errorf("%s (%s): %q uses write wording; the refusal covers reads too", verb, tc.label, msg) + } + } + } +} From 959e4334cd63280fe9484780d6e511dd79fe63da Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:55:14 +0100 Subject: [PATCH 38/69] =?UTF-8?q?chore:=20resolve=20iss-2609291731336469?= =?UTF-8?q?=20=E2=80=94=20foreign-owned=20store=20refusal=20names=20its=20?= =?UTF-8?q?owner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609291731336469 Assisted-by: Claude:claude-opus-5-5 --- ...e-transcript-store-refuses-every-history-verb-reads.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609291731336469-the-transcript-store-refuses-every-history-verb-reads.md (81%) diff --git a/.abcd/work/issues/open/iss-2609291731336469-the-transcript-store-refuses-every-history-verb-reads.md b/.abcd/work/issues/resolved/iss-2609291731336469-the-transcript-store-refuses-every-history-verb-reads.md similarity index 81% rename from .abcd/work/issues/open/iss-2609291731336469-the-transcript-store-refuses-every-history-verb-reads.md rename to .abcd/work/issues/resolved/iss-2609291731336469-the-transcript-store-refuses-every-history-verb-reads.md index 00a7f1ef3..b049a0897 100644 --- a/.abcd/work/issues/open/iss-2609291731336469-the-transcript-store-refuses-every-history-verb-reads.md +++ b/.abcd/work/issues/resolved/iss-2609291731336469-the-transcript-store-refuses-every-history-verb-reads.md @@ -12,6 +12,10 @@ found_at: "internal/core/history/location.go" remedy: "Waits on ruling CB1: in Resolve (internal/core/history/location.go): if (a) the refusal of every verb stands, word it per verb ('refusing to read or write transcripts in it') so history list no longer reports a write; if (b), a read skips the mode narrowing with a Note and only a write refuses. Prove the answer with a test that stubs the records leaf's owner lookup to a foreign uid and asserts list, show and capture each get the ruled outcome." deferred_after: "v0.11.1" deferral_reason: "owes ruling CB1 (rulings-owed 2026-09-25): whether a foreign-owned records leaf keeps refusing every verb, or a read skips the narrowing with a Note and only a write refuses; the choice is a trust-boundary ruling for the product thinker, not a lane's call, and no working setup is lost meanwhile (the owner's writes into a root-owned 0o700 leaf already failed)." +resolution: "Ruling CB1: every verb still refuses a records leaf another account owns; the refusal now names the owning uid (and account name where it resolves) and says the store is another account's, with no write wording on read verbs." +impact: fix +resolved_by: + commit: "3674db3a4" --- The transcript store refuses every history verb, reads included, when its records leaf is owned by another uid, and says so with write wording: Resolve's owner check (internal/core/history/location.go:223) reports 'not owned by this account; refusing to write transcripts into it' even for history list. The realistic hit is a root container over a bind-mounted checkout with the local store declared, where reads and hook capture both refuse. Precedent runs both ways: rules/root.go and fsutil.go refuse foreign-owned reads, home.go admits a root-owned home. Fix-later shape from the review: on a foreign owner, skip the narrowing with a Note on a read and refuse only a write. @@ -21,3 +25,7 @@ The transcript store refuses every history verb, reads included, when its record - Why: CB1's two shapes, each with its change; the wording fault is real under both, so (a) still carries a fix. The ruling is unanswered and none is picked. - Sources (consulted 2026-09-29): git's safe.directory 'will refuse to even parse a Git config of a repository owned by someone else', reads included, and admits another owner only through an explicit declaration or the SUDO_UID case (https://raw.githubusercontent.com/git/git/master/Documentation/config/safe.adoc). That precedent supports (a) for data a later session reads back as context, and it is the evidence the ruling should weigh against (b)'s bind-mount convenience. - Rejected: admitting a root-owned leaf silently, which would read records any process with root could have planted. + +## Grounds + +- pursued: history list, show and capture over a foreign-owned records leaf each refuse with a message naming the owner and free of write wording (TestForeignOwnedRecordsLeafRefusalNamesTheOwner); a read verb that succeeded there, or any refusal mentioning a write, would show it wrong From ab7382da91d86e0ccaea616c31e3863cbe90ada3 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:01:23 +0100 Subject: [PATCH 39/69] feat(rules): teach a repository's own guard entries in SHELL Ruling CK1 of the product thinker (2026-09-29): "teach a repository's own guard entries in the SHELL domain: YES, generated the same way as the bundled registry." Every rules load now rebuilds SHELL, before any rules.json layer lands on it, from the registry abcd guard enforces in the repository (guard.LoadRepo: the bundled entries merged with .abcd/guard.json), through the same generator (Lessons, RecallTerms, the fixed aliases). A repository's hazard is taught and recalled exactly as a bundled one is. Decisions taken by the lane (recorded in DECISIONS.md): - A lesson whose words are the repository's (an entry the file adds, or a bundled entry whose tier, pattern, why or successor it changes) carries "(repo)" after its entry id (guard.RepoMark, Registry.LessonsOver). A fixture-only change teaches the bundled words and stays unmarked. The domain itself keeps its bundled provenance to every other contract. - A guard.json the guard refuses (unreadable, invalid, or an uncommitted weakening edit) is refused here too: SHELL teaches the registry the guard falls back to, never the refused entries, and a load note names the file and the reason on stderr from abcd rules and the hook. The rest of the rule set loads; failing it whole would silence PII and COMMITTING over one broken guard file. - The guard file's disabled switch does not silence the teaching, which keeps its own switches in rules.json (spc-16, "Config home"). The token-cost sentence in 03-configuration.md is extended to cover the repository's entries (about a hundred tokens each), and 17-guard.md, commands/guard.md, AGENTS.md, the marker block and the rules help text (regenerated reference) say the registry taught is the one enforced. itd-103 gets an Audit Notes line (H10 shape; no ADR's text changes). Refs: iss-2609300756163382 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/17-guard.md | 9 +- .../brief/05-internals/03-configuration.md | 30 +++-- ...ents-the-shell-commands-they-must-never.md | 2 + .abcd/work/DECISIONS.md | 1 + ...-domain-teaches-only-the-bundled-hazard.md | 15 +++ AGENTS.md | 5 +- commands/guard.md | 11 +- docs/reference/cli/commands.md | 12 +- .../ahoy/defaults/claude-md-marker-block.md | 5 +- internal/core/guard/teach.go | 57 +++++++-- internal/core/guard/teach_test.go | 50 ++++++++ internal/core/rules/rules.go | 7 +- internal/core/rules/shell.go | 60 +++++++-- internal/core/rules/shell_test.go | 115 +++++++++++++++++- internal/surface/cli/cli.go | 12 +- internal/surface/cli/rules_shell_test.go | 61 ++++++++++ 16 files changed, 399 insertions(+), 53 deletions(-) create mode 100644 .abcd/work/issues/open/iss-2609300756163382-the-shell-rules-domain-teaches-only-the-bundled-hazard.md diff --git a/.abcd/development/brief/04-surfaces/17-guard.md b/.abcd/development/brief/04-surfaces/17-guard.md index bda67ad7b..681c83a79 100644 --- a/.abcd/development/brief/04-surfaces/17-guard.md +++ b/.abcd/development/brief/04-surfaces/17-guard.md @@ -82,9 +82,12 @@ the safe successor, recalled by the commands the registry names (`rm`, work injects those rules before the agent acts, so a host without hook support is still taught the safe form and a host with hooks is taught it before the guard would have to refuse. An entry added to the registry is taught and enforced from -the same release, with no second edit. The domain is built from the bundled -registry, not from a repo's `.abcd/guard.json`; how the domain is recalled, -overridden and silenced is the rules loader's +the same release, with no second edit. The registry taught is the one the guard +enforces in the repository: an entry the repository adds in its +`.abcd/guard.json` is taught by the same generator as the bundled ones, its +rule marked `(repo)` after its entry id, and a guard file the guard refuses is +named on stderr and never taught. How the domain is recalled, overridden and +silenced is the rules loader's ([`05-internals/03-configuration.md`](../05-internals/03-configuration.md)). ## The question gate diff --git a/.abcd/development/brief/05-internals/03-configuration.md b/.abcd/development/brief/05-internals/03-configuration.md index b1f653ba7..3ab83822e 100644 --- a/.abcd/development/brief/05-internals/03-configuration.md +++ b/.abcd/development/brief/05-internals/03-configuration.md @@ -537,8 +537,11 @@ repository restates in its own words, so a replacement there is not reported. **One bundled domain is generated.** `SHELL` is the teaching plane of the shell-hazard guard (itd-103, spc-16 "Two planes, one registry"): its rules and -recall keywords are built at start-up from the same bundled hazard registry -`abcd guard` enforces, never written in the bundled `rules.json`. Each registry +recall keywords are built from the same hazard registry `abcd guard` enforces, +never written in the bundled `rules.json`. The bundled set carries it built from +the bundled registry; every load rebuilds it, by the same generator, from the +registry the guard enforces in the repository, which is the bundled entries +merged with the repository's own `.abcd/guard.json` (ruling CK1). Each registry entry becomes one rule — whether the guard refuses or warns, the entry id, the command it matches, the plain-language why, and the safe successor — in entry-id order. The recall keywords are the command heads the registry matches (`rm`, @@ -550,12 +553,23 @@ domain with no second edit, and a test fails the build if the domain and the registry ever part. To every other contract it is an ordinary bundled domain: a user or repo layer overrides it per field, `dormant` silences it, `*SHELL` activates it, the kill switch suppresses it, and dedup and provenance treat it -like any other. Its injected block costs about 2k tokens, one rule per registry -entry, paid once per session per signature: dedup never injects it again while -its rules are unchanged. It is built from the bundled registry only: a repo's -`.abcd/guard.json` changes what the guard refuses there, and the two features -keep independent switches, so a repo that wants its own entries taught states -them in its `rules.json`. +like any other. Its injected block costs about 2k tokens for the bundled +registry, one rule per registry entry, and each entry a repository adds or +rewords in its `.abcd/guard.json` adds its own rule, about a hundred tokens at +the length of a bundled lesson; the block is paid once per session per +signature, so dedup never injects it again while its rules are unchanged, and +an edit to the guard file re-injects it once. A rule whose words are the +repository's — an entry the file adds, or a bundled entry whose tier, pattern, +why or successor it changes — carries `(repo)` after its entry id, so whose +words an agent is taught is never invisible; a fixture-only change teaches +the bundled words and is not marked. A `.abcd/guard.json` the guard refuses +(unreadable, invalid, or an uncommitted edit that weakens it) is refused here +too and never skipped in silence: `SHELL` teaches the registry the guard falls +back to, none of the refused entries, and the load names the file and the +reason on stderr, from `abcd rules` and from the hook on every prompt, while +every other domain loads as usual. The switches stay independent: the guard +file decides what is refused, and `rules.json` overrides, silences or kills the +teaching of it. ## The prompt router's output diff --git a/.abcd/development/intents/shipped/itd-103-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md b/.abcd/development/intents/shipped/itd-103-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md index 0684839d9..6d6611fbe 100644 --- a/.abcd/development/intents/shipped/itd-103-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md +++ b/.abcd/development/intents/shipped/itd-103-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md @@ -93,3 +93,5 @@ Gap audit: evidence: internal/core/rules/rules.go:401 — "no guard/safety/hazard domain is registered — the only `guard` here is the stemming short-token guard" Changed on 2026-09-29 by the product thinker's ruling J10 of that day (the DECISIONS.md entry landing with this change), recorded as iss-151: the teaching plane the headline promises, missing from the delivery reviewed above, is built. The rules loader bundles a `SHELL` domain generated from the same bundled hazard registry the guard reads, one rule per entry (the command, whether the guard refuses or warns, the why and the safe successor) recalled by the registry's command heads, so a prompt about shell-heavy work is taught the safe form before a command runs, on a host without hooks as on one with them. The criteria above stand as shipped; none of them concerned the teaching plane, and no ADR's text changes. + +Changed on 2026-09-30 by the product thinker's ruling CK1 of 2026-09-29 (the DECISIONS.md entry landing with this change), recorded as iss-2609300756163382: the `SHELL` domain teaches the registry the guard enforces in the repository, not the bundled registry alone. An entry a repository adds in its `.abcd/guard.json` is taught by the same generator as the bundled ones, its rule marked `(repo)` after its entry id, and a guard file the guard refuses is named on every load and never taught. The criteria above stand as shipped, and no ADR's text changes. diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 25682bb00..1c77a1285 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2600,3 +2600,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-29 — The 2026-09-29 entry applying ruling J15 names one plain-path consequence of the ledger forgetting a stopped domain, a domain edited out of `rules.json` and back; there is a second, which needs no edit at all: a dormant domain activated with `*NAME` is in force for that prompt alone, so a prompt without the prefix drops it from the active set and the next `*NAME` renders it again (review-routerRemoval finding 2; recorded by fix round fix-routerRemoval of autonomous run A; Refs: iss-2608261550580260). Both follow from J15, since the domain left the set in between, and both are documented in brief `05-internals/03-configuration.md` "The prompt router's output"; the rule-loader text in `AGENTS.md` and the managed-repository marker block qualify "never re-injects unchanged rules" to a domain that stays in force. The earlier entry stands as written. - 2026-09-29 — Every new issue carries a remedy, and abcd's own automatic filers write one machine value when they have no fix (the product thinker's rulings BX3 and H12 of 2026-09-29, applied by lane remedyRequired of autonomous run A; partial of itd-82, whose decision 6 already required the field). BX3, verbatim: "REFUSE the filing; every new issue must carry remedy:". H12, verbatim: "'NO FIX YET' ALLOWED: automatic filers may write remedy 'none (filed automatically)'; the record is filed, drain skips it until a person writes a real remedy." As built: `capture` refuses a new issue with no remedy or a blank one, exit 2 and nothing written, naming `--remedy` and the machine value; the value is spelt once, `issueschema.MachineRemedy`, and written by every in-binary filer (the consistency pass, and an inbox report promoted without a remedy of its own, whose own remedy otherwise becomes the issue's); `abcd drain --dry-run` lists a record carrying it as ineligible, naming the automatic filer and the verb that answers it; and `capture remedy <iss-N> "<fix>"` writes or replaces the remedy on an open issue. A record filed before the rule carries none, stays readable and valid, and is listed as ineligible. The one-line capture friction that `commands/capture.md` and the principle `adversarial-review-scales-with-blast-radius` promised is tightened by the ruling, and both now say so. A person typing the machine value is REFUSED, by `capture --remedy` and by `capture remedy`, compared trimmed and case-folded: the value is useful only while it means that a machine filed the record, and a person has either a fix to name or the choice not to file; allowing it would let a hand-filed record pass as machine-filed and be skipped silently. A remedy chosen in an autonomous run cites its grounds, a prior-art or state-of-the-art check where the fix depends on outside practice (principle `prefer-sota`), in the capture's text; nothing checks that mechanically yet. - 2026-09-30 — Pending the person's ruling CL1, a promoted inbox report files the machine value: outside text never becomes a drain-eligible remedy without a person naming it (fix round of lane remedyRequired, autonomous run A, closing the review's trust finding on the entry of 2026-09-29 above). As built: `inbox promote` always writes `remedy: none (filed automatically)` (`issueschema.MachineRemedy`), whatever the report proposes, so `abcd drain --dry-run` lists the issue as ineligible; the sender's proposal stays in the issue's text under "Remedy the reporter proposes:", scrubbed like every other value the report carries, for a person to adopt with `capture remedy`. This narrows the entry above, which filed a report's own remedy as the issue's; the ruling CL1 may widen it again. +- 2026-09-30 — Ruling CK1 of the product thinker (2026-09-29), applied (lane teachRepoGuard, autonomous run A; Refs: iss-2609300756163382): the `SHELL` domain teaches a repository's own guard entries, generated the same way as the bundled registry (the ruling, verbatim: "YES, generated the same way as the bundled registry"). This replaces the consequence the entry for ruling J10 above recorded, that the domain is built from the bundled registry only. Every rules load rebuilds `SHELL`, before any `rules.json` layer lands on it, from the registry `abcd guard` enforces in the repository (`guard.LoadRepo`: the bundled entries merged with `.abcd/guard.json`), through the same generator. Taken by the lane rather than the ruling: a lesson whose words are the repository's (an entry the file adds, or a bundled entry whose tier, pattern, why or successor it changes) carries `(repo)` after its entry id, so provenance stays visible while the domain itself stays bundled to every other contract; a guard file the guard refuses is refused here too, `SHELL` teaching the registry the guard falls back to and a load note naming the file and the reason on stderr from `abcd rules` and the hook, rather than failing the whole rule set (which would silence `PII` and `COMMITTING` over one broken guard file); the guard file's `disabled` switch does not silence the teaching, which keeps its own switches in `rules.json` (spc-16, "Config home"). diff --git a/.abcd/work/issues/open/iss-2609300756163382-the-shell-rules-domain-teaches-only-the-bundled-hazard.md b/.abcd/work/issues/open/iss-2609300756163382-the-shell-rules-domain-teaches-only-the-bundled-hazard.md new file mode 100644 index 000000000..e8bf943f4 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300756163382-the-shell-rules-domain-teaches-only-the-bundled-hazard.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300756163382" +slug: "the-shell-rules-domain-teaches-only-the-bundled-hazard" +severity: "minor" +category: "inconsistency" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25 (lane teachRepoGuard, ruling CK1; question raised by lane teachPlane)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/rules/shell.go" +remedy: "Rebuild SHELL on every rules load from the registry the guard enforces in the repository (guard.LoadRepo: the bundled entries merged with .abcd/guard.json), through the same generator (Lessons/RecallTerms), marking every lesson whose words are the repository's with (repo) after its entry id so provenance is visible (GHSA-22f8-qf5r-gjgq); a guard.json the guard refuses is refused here too, loudly (a load note on stderr naming the file and reason) and never taught, while SHELL teaches the registry the guard falls back to. Grounds: ruling CK1 verbatim, and J10's single-source rule (the registry is the one source)." +--- + +The SHELL rules domain teaches only the bundled hazard registry: an entry a repository adds in its own .abcd/guard.json is refused by the guard but never taught before shell work, so the teaching plane and the execution plane of itd-103 part in exactly the repositories that extended the registry. Ruling CK1 (2026-09-29, the product thinker): teach a repository's own guard entries in the SHELL domain, generated the same way as the bundled registry. diff --git a/AGENTS.md b/AGENTS.md index d369d2f75..6c7b2fbb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,8 +79,9 @@ trust rule for load experiments: one owned process group killed together through a re-checked handle and never by pattern, clean proven by what is running, and explicit consent with a cap below the core count on a live development machine. `SHELL` is the teaching half of the shell-hazard guard: it is generated from the -same bundled hazard registry `abcd guard` enforces, one rule per entry (the -command, why it is dangerous, and what to run instead), and recalls on the +same hazard registry `abcd guard` enforces in the repository, one rule per entry +(the command, why it is dangerous, and what to run instead; a rule from the +repository's own `.abcd/guard.json` is marked `(repo)`), and recalls on the commands the registry names (`rm`, `git push`, `pkill`, …) and on shell work in general, so an agent is taught the safe form before a host with hooks would refuse the command and a host without hooks still teaches it. diff --git a/commands/guard.md b/commands/guard.md index 45160b429..b7b1d4495 100644 --- a/commands/guard.md +++ b/commands/guard.md @@ -133,11 +133,12 @@ UNGUARDED warning naming the file, so the state cannot pass unnoticed. **Never write `.abcd/guard.json` on your own initiative.** Disabling or retiering a hazard is the user's decision to make and to review. -The bundled hazards are also taught before any command runs: the rules loader's -`SHELL` domain is generated from them, one rule per entry, and is injected when a -prompt is about shell work (`abcd rules shell` renders it). It is built from the -bundled registry only, so an entry a repo adds in `.abcd/guard.json` is enforced -here without being taught there. +The hazards are also taught before any command runs: the rules loader's `SHELL` +domain is generated from the registry this guard enforces, one rule per entry, +and is injected when a prompt is about shell work (`abcd rules shell` renders +it). An entry a repo adds in `.abcd/guard.json` is taught there as well, its +rule marked `(repo)`; a guard file this guard refuses is named on stderr and +not taught. ### What this guard is diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index ba501dd52..38e031148 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -2623,10 +2623,14 @@ is named on stderr, with the file that set the list, here and on every hook prompt. To keep an entry, restate it in the list, or leave the field out to inherit the bundled list. -SHELL is generated from the bundled shell-hazard registry that "abcd guard" -enforces: one rule per registry entry, naming the command, why it is dangerous -and what to run instead, recalled by the commands the registry names. It -teaches before shell work what the guard refuses at the moment a command runs. +SHELL is generated from the shell-hazard registry that "abcd guard" enforces +in this repository, the bundled entries and the repository's own +.abcd/guard.json entries alike: one rule per registry entry, naming the +command, why it is dangerous and what to run instead, recalled by the commands +the registry names. A rule in the repository's words is marked "(repo)" after +its entry id. A guard.json the guard refuses is named on stderr and not taught; +SHELL then teaches the registry the guard enforces in its place. It teaches +before shell work what the guard refuses at the moment a command runs. Read-only. ### `abcd scribe` diff --git a/internal/core/ahoy/defaults/claude-md-marker-block.md b/internal/core/ahoy/defaults/claude-md-marker-block.md index 0f09777e6..8327ab194 100644 --- a/internal/core/ahoy/defaults/claude-md-marker-block.md +++ b/internal/core/ahoy/defaults/claude-md-marker-block.md @@ -76,8 +76,9 @@ trust rule for load experiments: one owned process group killed together through a re-checked handle and never by pattern, clean proven by what is running, and explicit consent with a cap below the core count on a live development machine. `SHELL` is the teaching half of the shell-hazard guard: it is generated from the -same bundled hazard registry `abcd guard` enforces, one rule per entry (the -command, why it is dangerous, and what to run instead), and recalls on the +same hazard registry `abcd guard` enforces in the repository, one rule per entry +(the command, why it is dangerous, and what to run instead; a rule from the +repository's own `.abcd/guard.json` is marked `(repo)`), and recalls on the commands the registry names (`rm`, `git push`, `pkill`, …) and on shell work in general, so an agent is taught the safe form before a host with hooks would refuse the command and a host without hooks still teaches it. diff --git a/internal/core/guard/teach.go b/internal/core/guard/teach.go index 1720ace04..d69880545 100644 --- a/internal/core/guard/teach.go +++ b/internal/core/guard/teach.go @@ -7,10 +7,12 @@ import ( ) // teach.go is the registry's teaching-plane vocabulary (spc-16, "Two planes, one -// registry"; iss-151). The rules loader builds its bundled SHELL domain from -// these renderings, so what an agent is taught before shell-heavy work is what -// the guard would say at the moment of refusal, drawn from the one registry: -// an entry added or removed changes both planes with no second edit. +// registry"; iss-151, ruling CK1). The rules loader builds its SHELL domain +// from these renderings, over the registry the guard enforces in the +// repository (the bundled entries and the repository's own), so what an agent +// is taught before shell-heavy work is what the guard would say at the moment +// of refusal, drawn from the one registry: an entry added or removed changes +// both planes with no second edit. // maxTaughtValues caps how many operand words a description lists. An entry // like rm-rf-root-or-home carries every spelling of the home directory, and a @@ -18,9 +20,40 @@ import ( // hazard, and the count says the rest exist. const maxTaughtValues = 6 +// RepoMark is the provenance a lesson carries when its words are a +// repository's own rather than abcd's: an entry the repository's +// .abcd/guard.json added, or a bundled entry it reworded (ruling CK1). It +// follows the entry id, as a rules override's "(repo override)" follows the +// domain name, so whose words an agent is being taught is never invisible +// (GHSA-22f8-qf5r-gjgq). +const RepoMark = "(repo)" + // Lessons renders every entry's Lesson in entry-id order: the rules of the // teaching plane, one per registry entry. func (r Registry) Lessons() []string { + return r.lessons(func(string, Entry) bool { return false }) +} + +// LessonsOver renders r's lessons as Lessons does, one per entry in id order, +// and marks with RepoMark every lesson bundled does not teach word for word: +// an entry bundled lacks, or one whose lesson a repository layer changed (its +// tier, pattern, why or successor). A change no lesson shows — a fixture — +// leaves the bundled lesson unmarked, because the words taught are still +// abcd's. The registry is the guard's registry in force for the repository, so +// the plane teaches exactly what the guard enforces there. +func (r Registry) LessonsOver(bundled Registry) []string { + return r.lessons(func(id string, e Entry) bool { + b, ok := bundled.Entries[id] + if !ok { + return true + } + b.ID = id + return b.Lesson() != e.Lesson() + }) +} + +// lessons renders every entry in id order, marking those repo reports true for. +func (r Registry) lessons(repo func(id string, e Entry) bool) []string { ids := make([]string, 0, len(r.Entries)) for id := range r.Entries { ids = append(ids, id) @@ -30,7 +63,7 @@ func (r Registry) Lessons() []string { for _, id := range ids { e := r.Entries[id] e.ID = id - out = append(out, e.Lesson()) + out = append(out, e.lesson(repo(id, e))) } return out } @@ -59,13 +92,21 @@ func (r Registry) RecallTerms() []string { // Lesson is the one-line rule an entry teaches: whether the guard refuses or // warns, the entry id, the command it describes, the plain-language why, and // the safe successor. -func (e Entry) Lesson() string { +func (e Entry) Lesson() string { return e.lesson(false) } + +// lesson is Lesson with the repository's provenance mark after the id when +// repo is set. +func (e Entry) lesson(repo bool) string { lead := "Refused by the guard" if e.Tier == TierWarn { lead = "Warned by the guard" } - return fmt.Sprintf("%s (%s): %s. %s Instead: %s", - lead, e.ID, e.Pattern.Describe(), strings.TrimSpace(e.Why), strings.TrimSpace(e.Successor)) + id := "(" + e.ID + ")" + if repo { + id += " " + RepoMark + } + return fmt.Sprintf("%s %s: %s. %s Instead: %s", + lead, id, e.Pattern.Describe(), strings.TrimSpace(e.Why), strings.TrimSpace(e.Successor)) } // head is the command with its subcommands, space-joined. diff --git a/internal/core/guard/teach_test.go b/internal/core/guard/teach_test.go index c44a66e3f..2c88e778d 100644 --- a/internal/core/guard/teach_test.go +++ b/internal/core/guard/teach_test.go @@ -108,3 +108,53 @@ func TestRecallTermsAreTheCommandHeads(t *testing.T) { } } } + +// TestLessonsOverMarkTheRepositorysOwnWords: a repository's .abcd/guard.json +// entries are taught by the same generator as the bundled ones (ruling CK1), +// and every lesson whose words the bundled registry does not teach carries the +// "(repo)" mark, so whose words these are is never invisible: an entry the +// repository added, and a bundled entry it reworded. A change the lesson does +// not show (a fixture) leaves the bundled lesson unmarked. +func TestLessonsOverMarkTheRepositorysOwnWords(t *testing.T) { + bundled := Defaults() + if got, want := bundled.LessonsOver(bundled), bundled.Lessons(); !reflect.DeepEqual(got, want) { + t.Fatalf("the bundled registry over itself marked a lesson:\n got %q\nwant %q", got, want) + } + + repo := Defaults() + repo.Entries["deploy-prod"] = Entry{ + ID: "deploy-prod", + Pattern: Pattern{Command: "make", Subcommand: "deploy"}, + Tier: TierBlocker, + Why: "It deploys to production from a laptop.", + Successor: "Open a release pull request; CI deploys it.", + } + clean := repo.Entries["git-clean"] + clean.Why = "Untracked files here hold the fixtures nobody committed." + repo.Entries["git-clean"] = clean + reset := repo.Entries["git-reset-hard"] + reset.Fixtures.KnownGood = append(reset.Fixtures.KnownGood, "git reset --soft HEAD~1") + repo.Entries["git-reset-hard"] = reset + + got := map[string]string{} + for _, l := range repo.LessonsOver(bundled) { + for id := range repo.Entries { + if strings.Contains(l, "("+id+")") { + got[id] = l + } + } + } + if want := "Refused by the guard (deploy-prod) (repo): `make deploy`. It deploys to production from a laptop. Instead: Open a release pull request; CI deploys it."; got["deploy-prod"] != want { + t.Errorf("the repository's own entry teaches\n %q\nwant\n %q", got["deploy-prod"], want) + } + if !strings.HasPrefix(got["git-clean"], "Warned by the guard (git-clean) (repo): `git clean`.") || + !strings.Contains(got["git-clean"], clean.Why) { + t.Errorf("a bundled entry the repository reworded is not marked as the repository's: %q", got["git-clean"]) + } + if want := bundled.Entries["git-reset-hard"].Lesson(); got["git-reset-hard"] != want { + t.Errorf("a fixture-only change marked the bundled lesson:\n got %q\nwant %q", got["git-reset-hard"], want) + } + if n := len(repo.LessonsOver(bundled)); n != len(repo.Entries) { + t.Errorf("LessonsOver gave %d lessons for %d entries", n, len(repo.Entries)) + } +} diff --git a/internal/core/rules/rules.go b/internal/core/rules/rules.go index f897f2539..f1326ff16 100644 --- a/internal/core/rules/rules.go +++ b/internal/core/rules/rules.go @@ -237,10 +237,13 @@ func Load(repoRoot string) (RuleSet, error) { if err != nil { return RuleSet{}, err } + // SHELL is regenerated from the guard registry in force for this + // repository before any rules.json layer lands on it (ruling CK1). + base := withRepoShellDomain(Defaults(), repoRoot) if !haveUser && !haveRepo { - return Defaults(), nil + return base, nil } - merged := Defaults() + merged := base if haveUser { merged = mergeFrom(merged, user, SourceUser) // The user layer is validated on its own before the repo layer lands, diff --git a/internal/core/rules/shell.go b/internal/core/rules/shell.go index f7026d522..3ca5bb1c7 100644 --- a/internal/core/rules/shell.go +++ b/internal/core/rules/shell.go @@ -1,6 +1,10 @@ package rules -import "github.com/intentdriven/abcd/internal/core/guard" +import ( + "fmt" + + "github.com/intentdriven/abcd/internal/core/guard" +) // ShellDomain is the bundled domain that carries itd-103's teaching plane // (spc-16, "Two planes, one registry"; iss-151, ruling J10): the shell-hazard @@ -18,10 +22,14 @@ import "github.com/intentdriven/abcd/internal/core/guard" // *SHELL star command, dedup and provenance — and a guardrail domain to // noteWithheld. // -// It is built from the BUNDLED registry only. A repo's .abcd/guard.json -// changes what the guard refuses in that repo, and the two features keep -// independent switches (spc-16, "Config home"); a repo that wants its own -// entries taught says so in its rules.json, as for any bundled domain. +// The bundled defaults carry the domain generated from the bundled registry. +// Load regenerates it, by the same generator, from the registry the guard +// enforces in the repository — the bundled entries merged with the +// repository's own .abcd/guard.json (ruling CK1) — so a repository's own +// hazards are taught as the bundled ones are, each such lesson marked +// guard.RepoMark. The switches stay independent (spc-16, "Config home"): the +// guard file decides what is refused, and rules.json overrides, silences or +// kills the teaching of it like any bundled domain's. const ShellDomain = "SHELL" // shellAliases is the fixed half of the domain's recall: words that name @@ -33,11 +41,12 @@ const ShellDomain = "SHELL" // the domain alone. var shellAliases = []string{"bash", "command line", "force push", "shell", "zsh"} -// shellDomain generates the teaching-plane domain from reg. ok is false when -// the registry has no entries: a domain with no rules would render as a heading -// with nothing under it, the shape Validate refuses. -func shellDomain(reg guard.Registry) (Domain, bool) { - lessons := reg.Lessons() +// shellDomain generates the teaching-plane domain from reg, marking every +// lesson bundled does not teach word for word as the repository's. ok is false +// when the registry has no entries: a domain with no rules would render as a +// heading with nothing under it, the shape Validate refuses. +func shellDomain(reg, bundled guard.Registry) (Domain, bool) { + lessons := reg.LessonsOver(bundled) if len(lessons) == 0 { return Domain{}, false } @@ -57,7 +66,7 @@ func withShellDomain(rs RuleSet) RuleSet { if _, ok := rs.Domains[ShellDomain]; ok { panic("rules: bundled defaults declare " + ShellDomain + " by hand; it is generated from the guard registry") } - if d, ok := shellDomain(guard.Defaults()); ok { + if d, ok := shellDomain(guard.Defaults(), guard.Defaults()); ok { if rs.Domains == nil { rs.Domains = map[string]Domain{} } @@ -65,3 +74,32 @@ func withShellDomain(rs RuleSet) RuleSet { } return rs } + +// withRepoShellDomain regenerates the SHELL domain of the bundled set rs from +// the registry the guard enforces in repoRoot (ruling CK1): the bundled +// entries merged with the repository's .abcd/guard.json. It runs before any +// rules.json layer, so the regenerated domain is the base those layers +// override per field, exactly as they override the bundled one. +// +// A guard.json the guard refuses — unreadable, invalid, or an uncommitted +// edit that weakens it — is refused here too, loudly: the domain teaches the +// registry the guard falls back to (the bundled one, or HEAD's committed +// file), never the refused entries, and a note names the file and the reason +// on every load. Failing the whole rule set instead would take PII, COMMITTING +// and every other domain down with one broken guard file; skipping it silently +// would leave a repository believing its own hazard is taught. +func withRepoShellDomain(rs RuleSet, repoRoot string) RuleSet { + ld := guard.LoadRepo(repoRoot) + if ld.Err != nil { + rs.notes = append(rs.notes, fmt.Sprintf( + "rules: %s: the repository's own guard entries are refused and not taught (%v); %s teaches the registry the guard enforces in their place", + ShellDomain, ld.Err, ShellDomain)) + } + if ld.Posture == guard.LoadUnavailable { + return rs + } + if d, ok := shellDomain(ld.Registry, guard.Defaults()); ok { + rs.Domains[ShellDomain] = d + } + return rs +} diff --git a/internal/core/rules/shell_test.go b/internal/core/rules/shell_test.go index 583e17b01..5a47198d1 100644 --- a/internal/core/rules/shell_test.go +++ b/internal/core/rules/shell_test.go @@ -2,6 +2,8 @@ package rules import ( "encoding/json" + "os" + "path/filepath" "reflect" "strings" "testing" @@ -62,7 +64,7 @@ func TestShellDomainIsNotHandWritten(t *testing.T) { // removing one removes both. func TestShellDomainFollowsRegistryEdits(t *testing.T) { reg := guard.Defaults() - base, ok := shellDomain(reg) + base, ok := shellDomain(reg, reg) if !ok { t.Fatal("the bundled registry generated no domain") } @@ -75,7 +77,7 @@ func TestShellDomainFollowsRegistryEdits(t *testing.T) { Successor: "Delete the one file you mean with rm.", Why: "Shredding cannot be undone.", } - grown, _ := shellDomain(added) + grown, _ := shellDomain(added, added) if len(grown.Rules) != len(base.Rules)+1 { t.Fatalf("adding an entry gave %d rules, want %d", len(grown.Rules), len(base.Rules)+1) } @@ -88,7 +90,7 @@ func TestShellDomainFollowsRegistryEdits(t *testing.T) { removed := guard.Defaults() delete(removed.Entries, "git-clean") - shrunk, _ := shellDomain(removed) + shrunk, _ := shellDomain(removed, removed) if len(shrunk.Rules) != len(base.Rules)-1 { t.Fatalf("removing an entry gave %d rules, want %d", len(shrunk.Rules), len(base.Rules)-1) } @@ -102,7 +104,7 @@ func TestShellDomainFollowsRegistryEdits(t *testing.T) { } // An empty registry generates no domain at all, never a heading-only one. - if _, ok := shellDomain(guard.Registry{SchemaVersion: guard.SchemaVersion}); ok { + if _, ok := shellDomain(guard.Registry{SchemaVersion: guard.SchemaVersion}, guard.Defaults()); ok { t.Fatal("an empty registry generated a domain; it would render as a heading with nothing under it") } } @@ -231,3 +233,108 @@ func mustLoad(t *testing.T, dir string) RuleSet { } return rs } + +// repoGuardEntry is a repository's own hazard, declared in its +// .abcd/guard.json, and repoGuardLesson the one rule it teaches: the text is +// pinned here so a change to the generator's wording is a change someone saw. +const ( + repoGuardEntry = `{"schema_version":1,"entries":{"deploy-prod":{ + "tier":"blocker", + "pattern":{"command":"make","subcommand":"deploy"}, + "why":"It deploys to production from a laptop.", + "successor":"Open a release pull request; CI deploys it."}}}` + repoGuardLesson = "Refused by the guard (deploy-prod) (repo): `make deploy`. It deploys to production from a laptop. Instead: Open a release pull request; CI deploys it." +) + +func writeRepoGuard(t *testing.T, dir, body string) { + t.Helper() + abcd := filepath.Join(dir, ".abcd") + if err := os.MkdirAll(abcd, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(abcd, "guard.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// TestShellDomainTeachesTheRepositorysOwnGuardEntries (ruling CK1): an entry +// a repository adds in its .abcd/guard.json is taught in SHELL by the same +// generator as the bundled ones — its lesson in id order among them, marked +// "(repo)", and its command head a recall term — whether or not the +// repository also carries a rules.json. +func TestShellDomainTeachesTheRepositorysOwnGuardEntries(t *testing.T) { + bundled := Defaults().Domains[ShellDomain] + for _, withRules := range []bool{false, true} { + dir := t.TempDir() + writeRepoGuard(t, dir, repoGuardEntry) + if withRules { + writeRepoRules(t, dir, `{"schema_version":1,"domains":{}}`) + } + rs := mustLoad(t, dir) + d, ok := rs.Lookup(ShellDomain) + if !ok { + t.Fatalf("rules.json=%v: no %s domain", withRules, ShellDomain) + } + if !holds(d.Rules, repoGuardLesson) { + t.Errorf("rules.json=%v: the repository's own entry is not taught as\n %q\namong the %d rules", withRules, repoGuardLesson, len(d.Rules)) + } + if len(d.Rules) != len(bundled.Rules)+1 { + t.Errorf("rules.json=%v: %d lessons, want the %d bundled ones plus the repository's", withRules, len(d.Rules), len(bundled.Rules)) + } + for _, l := range bundled.Rules { + if !holds(d.Rules, l) { + t.Errorf("rules.json=%v: a bundled lesson is missing or marked: %q", withRules, l) + } + } + if !holds(d.Recall, "make deploy") { + t.Errorf("rules.json=%v: the entry's command head is not a recall term: %q", withRules, d.Recall) + } + if !has(rs.Match("make deploy the docs site"), ShellDomain) { + t.Errorf("rules.json=%v: a prompt naming the repository's hazard did not recall %s", withRules, ShellDomain) + } + if d.Source != SourceBundled { + t.Errorf("rules.json=%v: source %q; the domain is still the generated one, its repository words marked per lesson", withRules, d.Source) + } + if n := rs.Notes(); len(n) != 0 { + t.Errorf("rules.json=%v: a valid guard.json produced notes: %q", withRules, n) + } + } +} + +// TestShellDomainRefusesAnInvalidRepoGuardEntryLoudly (ruling CK1): a +// repository guard.json the guard refuses is refused here too, never taught +// and never silently skipped. SHELL teaches the registry the guard enforces in +// its place (the bundled one), the rest of the rule set loads, and a note +// names the file and the reason on every load. +func TestShellDomainRefusesAnInvalidRepoGuardEntryLoudly(t *testing.T) { + dir := t.TempDir() + writeRepoGuard(t, dir, `{"schema_version":1,"entries":{"deploy-prod":{ + "tier":"blocker", + "pattern":{"command":"make","subcommand":"deploy"}, + "successor":"Open a release pull request; CI deploys it."}}}`) + rs := mustLoad(t, dir) + d, _ := rs.Lookup(ShellDomain) + if want := Defaults().Domains[ShellDomain].Rules; !reflect.DeepEqual(d.Rules, want) { + t.Errorf("an invalid repository entry changed what SHELL teaches: %d rules, want the %d bundled ones", len(d.Rules), len(want)) + } + for _, r := range d.Rules { + if strings.Contains(r, "deploy-prod") { + t.Errorf("the refused entry is taught: %q", r) + } + } + if holds(d.Recall, "make deploy") { + t.Errorf("the refused entry's head is a recall term: %q", d.Recall) + } + if _, ok := rs.Lookup("PII"); !ok { + t.Error("a refused guard.json took the rest of the rule set with it") + } + var note string + for _, n := range rs.Notes() { + if strings.Contains(n, ShellDomain) && strings.Contains(n, ".abcd/guard.json") { + note = n + } + } + if note == "" || !strings.Contains(note, "deploy-prod has no why") || !strings.Contains(note, "refused") { + t.Fatalf("the refused guard.json is not named loudly; notes: %q", rs.Notes()) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 4d921ff2b..6b48ec45b 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -2119,10 +2119,14 @@ is named on stderr, with the file that set the list, here and on every hook prompt. To keep an entry, restate it in the list, or leave the field out to inherit the bundled list. -SHELL is generated from the bundled shell-hazard registry that "abcd guard" -enforces: one rule per registry entry, naming the command, why it is dangerous -and what to run instead, recalled by the commands the registry names. It -teaches before shell work what the guard refuses at the moment a command runs. +SHELL is generated from the shell-hazard registry that "abcd guard" enforces +in this repository, the bundled entries and the repository's own +.abcd/guard.json entries alike: one rule per registry entry, naming the +command, why it is dangerous and what to run instead, recalled by the commands +the registry names. A rule in the repository's words is marked "(repo)" after +its entry id. A guard.json the guard refuses is named on stderr and not taught; +SHELL then teaches the registry the guard enforces in its place. It teaches +before shell work what the guard refuses at the moment a command runs. Read-only.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/surface/cli/rules_shell_test.go b/internal/surface/cli/rules_shell_test.go index 884cce20f..a8f4a1e42 100644 --- a/internal/surface/cli/rules_shell_test.go +++ b/internal/surface/cli/rules_shell_test.go @@ -2,6 +2,8 @@ package cli import ( "encoding/json" + "os" + "path/filepath" "strings" "testing" ) @@ -47,3 +49,62 @@ func TestHookPromptRouterTeachesShellHazards(t *testing.T) { t.Fatalf("the unchanged SHELL domain re-injected within one session:\n%s", again) } } + +// writeGuardFile lays a repository's own .abcd/guard.json into dir. +func writeGuardFile(t *testing.T, dir, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".abcd", "guard.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// TestRulesTeachesTheRepositorysOwnGuardEntries (ruling CK1): an entry a +// repository adds in its .abcd/guard.json is taught through both front doors, +// marked as the repository's; a guard.json the guard refuses is named on +// stderr from both, and SHELL still teaches the bundled lessons. +func TestRulesTeachesTheRepositorysOwnGuardEntries(t *testing.T) { + const entry = `{"schema_version":1,"entries":{"deploy-prod":{ + "tier":"blocker", + "pattern":{"command":"make","subcommand":"deploy"}, + "why":"It deploys to production from a laptop.", + "successor":"Open a release pull request; CI deploys it."}}}` + const lesson = "- Refused by the guard (deploy-prod) (repo): `make deploy`. It deploys to production from a laptop. Instead: Open a release pull request; CI deploys it.\n" + + t.Run("taught", func(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + cwd := t.TempDir() + writeGuardFile(t, cwd, entry) + out, errlog := runHook(t, hookInputJSON(t, "teach-repo", cwd, "make deploy the docs site"), "hook", "prompt-router") + if !strings.Contains(out, "## SHELL\n") || !strings.Contains(out, lesson) { + t.Fatalf("the repository's own hazard was not taught by the hook:\n%s\nstderr:\n%s", out, errlog) + } + t.Chdir(cwd) + so, se, err := runCLISplit(t, "rules", "shell") + if err != nil || !strings.Contains(so, lesson) || se != "" { + t.Fatalf("abcd rules shell: err=%v\nstdout:\n%s\nstderr:\n%s", err, so, se) + } + }) + + t.Run("refused loudly", func(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + cwd := t.TempDir() + writeGuardFile(t, cwd, strings.Replace(entry, `"why":"It deploys to production from a laptop.",`, "", 1)) + out, errlog := runHook(t, hookInputJSON(t, "teach-repo-bad", cwd, "rm -rf the old build output"), "hook", "prompt-router") + if !strings.Contains(out, "(rm-rf-root-or-home)") || strings.Contains(out, "deploy-prod") { + t.Fatalf("a refused guard.json changed the bundled lessons:\n%s", out) + } + for _, want := range []string{"SHELL", ".abcd/guard.json", "deploy-prod has no why", "refused and not taught"} { + if !strings.Contains(errlog, want) { + t.Fatalf("the hook's stderr does not name the refusal (%q):\n%s", want, errlog) + } + } + t.Chdir(cwd) + _, se, err := runCLISplit(t, "rules", "shell") + if err != nil || !strings.Contains(se, "refused and not taught") { + t.Fatalf("abcd rules shell did not name the refused guard.json: err=%v\nstderr:\n%s", err, se) + } + }) +} From b4fe21075ce0491c5e4f8f629c0443ab45210e08 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:01:35 +0100 Subject: [PATCH 40/69] =?UTF-8?q?chore:=20resolve=20iss-2609300756163382?= =?UTF-8?q?=20=E2=80=94=20SHELL=20teaches=20a=20repository's=20own=20guard?= =?UTF-8?q?=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling CK1 applied in ab7382da9. Resolves: iss-2609300756163382 Assisted-by: Claude:claude-opus-5-5 --- ...-shell-rules-domain-teaches-only-the-bundled-hazard.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609300756163382-the-shell-rules-domain-teaches-only-the-bundled-hazard.md (64%) diff --git a/.abcd/work/issues/open/iss-2609300756163382-the-shell-rules-domain-teaches-only-the-bundled-hazard.md b/.abcd/work/issues/resolved/iss-2609300756163382-the-shell-rules-domain-teaches-only-the-bundled-hazard.md similarity index 64% rename from .abcd/work/issues/open/iss-2609300756163382-the-shell-rules-domain-teaches-only-the-bundled-hazard.md rename to .abcd/work/issues/resolved/iss-2609300756163382-the-shell-rules-domain-teaches-only-the-bundled-hazard.md index e8bf943f4..f9401d117 100644 --- a/.abcd/work/issues/open/iss-2609300756163382-the-shell-rules-domain-teaches-only-the-bundled-hazard.md +++ b/.abcd/work/issues/resolved/iss-2609300756163382-the-shell-rules-domain-teaches-only-the-bundled-hazard.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/rules/shell.go" remedy: "Rebuild SHELL on every rules load from the registry the guard enforces in the repository (guard.LoadRepo: the bundled entries merged with .abcd/guard.json), through the same generator (Lessons/RecallTerms), marking every lesson whose words are the repository's with (repo) after its entry id so provenance is visible (GHSA-22f8-qf5r-gjgq); a guard.json the guard refuses is refused here too, loudly (a load note on stderr naming the file and reason) and never taught, while SHELL teaches the registry the guard falls back to. Grounds: ruling CK1 verbatim, and J10's single-source rule (the registry is the one source)." +resolution: "SHELL is rebuilt on every rules load from the registry the guard enforces in the repository, through the same generator; a repository's own entries are taught marked (repo), and a refused guard.json is named on stderr and never taught." +impact: additive +resolved_by: + commit: "ab7382da9" --- The SHELL rules domain teaches only the bundled hazard registry: an entry a repository adds in its own .abcd/guard.json is refused by the guard but never taught before shell work, so the teaching plane and the execution plane of itd-103 part in exactly the repositories that extended the registry. Ruling CK1 (2026-09-29, the product thinker): teach a repository's own guard entries in the SHELL domain, generated the same way as the bundled registry. + +## Grounds + +- pursued: a repository's own .abcd/guard.json entry appears in abcd rules shell and the hook's SHELL block marked (repo), recalled by its command head, while an invalid one is named on stderr and absent (TestShellDomainTeachesTheRepositorysOwnGuardEntries, TestShellDomainRefusesAnInvalidRepoGuardEntryLoudly, TestRulesTeachesTheRepositorysOwnGuardEntries); a guard.json entry the guard enforces that SHELL does not teach, or an invalid one taught or dropped with no note, would show it wrong From e9a393675796db09b0e4f71b78d2024b7e708705 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:02:49 +0100 Subject: [PATCH 41/69] feat(ahoy): offer to install a missing gh on a yes typed at a terminal The product thinker's DQ3 ruling (2026-09-29) says abcd offers to install the GitHub CLI on an explicit yes. ahoy remote apply and site setup, the two verbs that speak to GitHub through gh, used to refuse a missing gh with the registry's explanation and nothing more. ahoy.OfferGH runs the explain-then-install mode for gh. With gh on PATH it does nothing. Otherwise it hands the registry's explanation to the front door's confirmation and runs the registry's Homebrew step only on a yes. The verb goes on only when the step both installed and verified. A no, a caller that asked no one, CI, and a failed or unverified step each refuse before any request leaves the machine, with the reason first and then the explanation and the command. - ahoy remote apply asks after its three gates, so a verb that would refuse anyway asks nothing, and before the read that needs gh. - ahoy --remote is the read, so it writes nothing and never offers the install. It names the apply as the verb that does. - site setup asks at the forge stage. A declined offer leaves the environments uncreated, as an unreachable forge already does. At the CLI, terminalToolConfirm shares askToolAtTerminal with ahoy install's toolConfirm. Its only yes is one typed at a terminal: --yes, a piped answer and a prompter with no terminal each decline, and each decline carries the command to run by hand. The confirmation is built from the prompter before --yes swaps in alwaysConfirm, so --yes answers the settings change and never the install. Both --yes help strings say so, and the CLI reference is regenerated. --install-tool still names gitleaks alone, because the ruling as briefed admits only a terminal yes for gh. The plugin pages and the brief surface chapters describe the offer. Refs: iss-2609281911024838 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/01-ahoy.md | 11 + .../development/brief/04-surfaces/22-site.md | 6 +- commands/ahoy.md | 13 +- commands/site.md | 5 +- docs/reference/cli/commands.md | 4 +- internal/core/ahoy/remote.go | 46 ++++- internal/core/ahoy/remote_gh_offer_test.go | 191 ++++++++++++++++++ internal/core/ahoy/remote_test.go | 32 +-- internal/core/site/setup.go | 14 +- internal/core/site/setup_test.go | 63 ++++++ .../surface/cli/ahoy_remote_gh_offer_test.go | 108 ++++++++++ internal/surface/cli/cli.go | 34 +++- internal/surface/cli/site.go | 11 +- 13 files changed, 504 insertions(+), 34 deletions(-) create mode 100644 internal/core/ahoy/remote_gh_offer_test.go create mode 100644 internal/surface/cli/ahoy_remote_gh_offer_test.go diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index f584c327e..a7eb8ff74 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -119,6 +119,17 @@ the API host explicitly, so an ambient host variable cannot send the write to an endpoint the origin never named, and the call goes through the caller's own authenticated identity: abcd never holds a token. +That identity is the GitHub CLI's, so a missing `gh` is met with the +explain-then-install mode (itd-63): after the first three gates and before the +read, the verb explains `gh` from the tool registry and offers to install it, +running the registry's step only on a yes typed at a terminal. The pre-given +yes answers the settings change and never the install of a program, and a +piped answer is not a person's answer, so both decline the offer; the verb then +refuses, its notes carrying the explanation and the command. A failed or +unverified install refuses the same way, before any request leaves the +machine. The read never offers the install, because looking is never acting; +it names the apply as the verb that does. + ### The provider setup The setup takes the provider's name, its base URL, its first allowlist (every diff --git a/.abcd/development/brief/04-surfaces/22-site.md b/.abcd/development/brief/04-surfaces/22-site.md index dad329c4d..68e0758fe 100644 --- a/.abcd/development/brief/04-surfaces/22-site.md +++ b/.abcd/development/brief/04-surfaces/22-site.md @@ -62,7 +62,11 @@ no remote change attempted, unless the run is told to replace it. **The forge.** Two deployment environments, one for the render and one for the deploy, each admitting only the default branch and release tags, created -through the forge's API as the person running the verb. The default branch is +through the forge's API as the person running the verb. A missing `gh` is +offered for install on the same terms as the remote apply's: explained, and +installed only on a yes typed at a terminal, never on the pre-given yes; a +declined offer leaves the environments uncreated, with the command in the +notes. The default branch is the one the forge names for the repository, and it is also the branch the workflow gates on; only when the forge cannot answer does the checkout's own stand in, and the report's notes say so. An environment that diff --git a/commands/ahoy.md b/commands/ahoy.md index 4a0205ffd..3d1a628a0 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -398,10 +398,15 @@ would send this verb's authenticated write to a machine the origin URL never named. The call goes through the GitHub CLI (`gh`), so the write is made by the user's -own authenticated identity and abcd never holds a token; if `gh` is absent the -verb refuses, and the refusal carries the tool registry's explanation of `gh`: -what it is, that these verbs require it, the exact install step and what that -install does. Relay it; the step is the user's to run. It is idempotent — a repository already in the desired +own authenticated identity and abcd never holds a token. If `gh` is absent, the +verb explains it from the tool registry (what it is, that these verbs require +it, the exact install step and what that install does) and offers to install +it, running the step only on a yes typed at a terminal. `--yes` never answers +that offer, and neither does a piped answer, so through this page the offer is +declined: the verb refuses, and its notes carry the explanation and the command. +Relay them; the install is the user's to run, by hand or by running the verb at +a terminal. `ahoy --remote` never offers the install, since it writes nothing, +and names this verb as the one that does. It is idempotent — a repository already in the desired state takes no write, and a re-run rewrites nothing in the tree — and it stops at the first failed step rather than attempting one that cannot succeed. Relay `status`, the resolved `repo`, every `change`, and every `note`: a note is a diff --git a/commands/site.md b/commands/site.md index 04d2336a9..e67375e8a 100644 --- a/commands/site.md +++ b/commands/site.md @@ -107,7 +107,10 @@ sets up the site of a repository abcd manages, in three stages, and emits writes is `refused`, the whole run writes nothing, and `--confirm` replaces it. - `environments` — the forge's `site-render` and `site` deployment environments, each admitting only the default branch and tags `v*`, created - through `gh` as you. The default branch is the one the forge names; when the + through `gh` as you. When `gh` is missing, setup explains it and offers to + install it, running the step only on a yes typed at a terminal; under + `--yes` or a piped answer the offer is declined, the environments are not + created, and `notes` carries the command to run. The default branch is the one the forge names; when the forge cannot answer, the checkout's branch stands in and `notes` says so. An existing environment is never rewritten (the forge's write would replace its required reviewers): one on named rules and no rule beyond those two gains the rules it lacks, and one that admits more, through diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index ba501dd52..fd5e9444d 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -134,7 +134,7 @@ Enable GitHub secret scanning and push protection on this repository: Writes bot **Flags:** ``` - --yes confirm the remote change without being asked; without it an unanswered run declines and changes nothing + --yes confirm the remote change without being asked (never the install of a missing gh); without it an unanswered run declines and changes nothing ``` #### `abcd ahoy uninstall` @@ -2758,7 +2758,7 @@ Take the website from this checkout to a live address: Writes its files, and the --confirm replace a workflow or host configuration that differs from what setup writes --domain string custom domain to route to the host when the composition names none --name string host name when the composition names none (default: the repository's name) - --yes confirm the forge and host changes without being asked; without it an unanswered run declines them + --yes confirm the forge and host changes without being asked (never the install of a missing gh); without it an unanswered run declines them ``` ### `abcd source` diff --git a/internal/core/ahoy/remote.go b/internal/core/ahoy/remote.go index 4d73458bd..57dc1bad3 100644 --- a/internal/core/ahoy/remote.go +++ b/internal/core/ahoy/remote.go @@ -221,7 +221,14 @@ func RemoteRead(cwd string) (RemoteResult, error) { } observed, merge, err := ghSecurityState(abs, res.Repo) if err != nil { - return refuseRemote(res, "could not read "+res.Repo+"'s security settings, so nothing is known about what would change: "+errText(err)), nil + res = refuseRemote(res, "could not read "+res.Repo+"'s security settings, so nothing is known about what would change: "+errText(err)) + // The read installs nothing (looking is never acting); it names the + // verb that offers the install. + var missing *tools.MissingError + if errors.As(err, &missing) && missing.Explanation.Tool == "gh" { + res.Notes = append(res.Notes, "abcd ahoy remote apply offers to install it, and runs the step only on a yes typed at a terminal") + } + return res, nil } res.Observed, res.Merge = observed, merge res.Changes = pendingChanges(observed) @@ -248,7 +255,7 @@ func RemoteRead(cwd string) (RemoteResult, error) { // the default), which is also what lets the confirmation name exactly what would // change. Then secret scanning, then push protection: GitHub refuses push protection // on a repo whose secret scanning is off. -func RemoteApply(cwd string, p Prompter) (RemoteResult, error) { +func RemoteApply(cwd string, p Prompter, confirmTool tools.Confirm) (RemoteResult, error) { if p == nil { p = RefusingPrompter{} } @@ -256,6 +263,15 @@ func RemoteApply(cwd string, p Prompter) (RemoteResult, error) { if done { return res, nil } + // The gh offer comes after the gates, so a verb that would refuse anyway + // never asks to install anything, and before the read, which needs gh. + offer, ok := OfferGH(abs, confirmTool) + if !ok { + res = refuseRemote(res, offer[0]) + res.Notes = append(res.Notes, offer[1:]...) + return res, nil + } + res.Notes = append(res.Notes, offer...) observed, merge, err := ghSecurityState(abs, res.Repo) if err != nil { return refuseRemote(res, "could not read "+res.Repo+"'s security settings; nothing was changed, because a write over an unknown state is a guess: "+errText(err)), nil @@ -314,6 +330,32 @@ func RemoteApply(cwd string, p Prompter) (RemoteResult, error) { return res, nil } +// OfferGH is the explain-then-install mode for gh at a remote write (itd-63 +// criterion 2; the product thinker's DQ3 ruling, 2026-09-29: offer to install +// gh on an explicit yes). With gh on PATH it says nothing. Otherwise it hands +// the registry's explanation to confirm, which the front door answers yes only +// for a person at a terminal, and runs the registry's step only on that yes. +// +// ok is true only when gh was installed AND verified; the notes then say what +// ran. Every other outcome (a no, no one asked, CI, a failed or unverified +// step) is ok=false with the reason first, then the explanation with the exact +// command, so the caller refuses loudly and reaches no gh call on a half-done +// install. +func OfferGH(guard string, confirm tools.Confirm) (notes []string, ok bool) { + if onPath("gh") { + return nil, true + } + res := newToolInstaller(guard).Install("gh", tools.GitHubSettings, confirm) + if res.Ran && res.Installed && res.Verified { + return []string{res.Summary() + "; the install does not sign gh in, so if GitHub refuses the request, run gh auth login"}, true + } + notes = []string{"the GitHub CLI (gh) is not on PATH: " + res.Summary()} + if !res.Ran { + notes = append(notes, tools.Explain("gh", tools.GitHubSettings).Lines()...) + } + return notes, false +} + // remotePrepare runs the three gates both verbs share and returns done=true when // one of them settled the outcome. The gates are the adr-44 boundary: after this // returns done=false, and only then, may a request leave the machine. diff --git a/internal/core/ahoy/remote_gh_offer_test.go b/internal/core/ahoy/remote_gh_offer_test.go new file mode 100644 index 000000000..fca7fcf1f --- /dev/null +++ b/internal/core/ahoy/remote_gh_offer_test.go @@ -0,0 +1,191 @@ +package ahoy + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/tools" +) + +// ghOfferRig is a managed repository whose PATH holds git and nothing else, so +// gh is missing, and whose tool installer is a fake: a "brew install gh" it is +// told to run writes a stand-in gh onto that PATH (answering with body) and +// records the argv, so a test sees exactly what would have run and what the +// verb did once gh was there. +type ghOfferRig struct { + repo string + path string // the only PATH directory + calls [][]string + ghLog string + fail bool // the fake brew exits non-zero +} + +func newGHOfferRig(t *testing.T, body string) *ghOfferRig { + t.Helper() + setupHermetic(t) + repo := managedRepoWithOrigin(t, "https://github.com/example-org/example-repo") + gitBin, err := exec.LookPath("git") + if err != nil { + t.Skip("git unavailable") + } + r := &ghOfferRig{repo: repo, path: t.TempDir()} + r.ghLog = filepath.Join(t.TempDir(), "gh.log") + if err := os.Symlink(gitBin, filepath.Join(r.path, "git")); err != nil { + t.Fatal(err) + } + // brew is a real file the installer's admit can resolve; the fake Run never + // executes it. + if err := os.WriteFile(filepath.Join(r.path, "brew"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatal(err) + } + bodyFile := filepath.Join(t.TempDir(), "get.json") + if err := os.WriteFile(bodyFile, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", r.path) + + prev := newToolInstaller + newToolInstaller = func(guard string) *tools.Installer { + in := tools.Default(guard) + in.Getenv = func(string) string { return "" } + in.Run = func(_ context.Context, argv []string) ([]byte, error) { + r.calls = append(r.calls, argv) + if filepath.Base(argv[0]) != "brew" { + return []byte("gh version 2.0.0 (fake)\n"), nil + } + if r.fail { + return []byte("Error: gh: download failed\n"), errors.New("exit status 1") + } + script := "#!/bin/sh\necho \"$*\" >> '" + r.ghLog + "'\n/bin/cat '" + bodyFile + "'\n" + if err := os.WriteFile(filepath.Join(r.path, "gh"), []byte(script), 0o755); err != nil { + return nil, err + } + return []byte("==> Pouring gh\n"), nil + } + return in + } + t.Cleanup(func() { newToolInstaller = prev }) + return r +} + +func (r *ghOfferRig) ghRan(t *testing.T) bool { + t.Helper() + _, err := os.Stat(r.ghLog) + return err == nil +} + +// asked records what the confirmation was shown and answers ans. +func asked(seen *[]tools.Explanation, ans tools.Answer) tools.Confirm { + return func(e tools.Explanation) tools.Answer { + *seen = append(*seen, e) + return ans + } +} + +// TestRemoteApplyOffersGhAndRunsTheStepOnYes is the gh offer's yes (the DQ3 +// ruling, itd-63 criterion 2): a missing gh is put to the question with the +// registry's explanation, the yes runs the registry's step, the verb says what +// it ran, and the settings are then read through the gh it installed. +func TestRemoteApplyOffersGhAndRunsTheStepOnYes(t *testing.T) { + r := newGHOfferRig(t, bothEnabled) + var seen []tools.Explanation + res, err := RemoteApply(r.repo, confirmingPrompter{}, asked(&seen, tools.Answer{Yes: true, Why: "answered yes at the terminal"})) + if err != nil { + t.Fatal(err) + } + if len(seen) != 1 || seen[0].Tool != "gh" || seen[0].StepText() != "brew install gh" { + t.Fatalf("the offer was not put to the question with gh's explanation: %+v", seen) + } + if len(r.calls) == 0 || strings.Join(r.calls[0][1:], " ") != "install gh" { + t.Fatalf("the yes did not run the registry's step: %v", r.calls) + } + if res.Status == "refused" { + t.Fatalf("the verb refused after gh was installed: %+v", res) + } + if !r.ghRan(t) { + t.Fatal("the settings were not read through the installed gh") + } + notes := strings.Join(res.Notes, "\n") + for _, want := range []string{"gh: ran brew install gh — installed, and verified with gh --version", "gh auth login"} { + if !strings.Contains(notes, want) { + t.Errorf("the notes lack %q:\n%s", want, notes) + } + } +} + +// TestRemoteApplyGhDeclinedRunsNothingAndShowsTheStep is every no: an answer +// of no, and a caller that asked no one (off a terminal the front door answers +// no itself). Nothing runs, gh is never reached, and the refusal carries the +// explanation with the exact command to run by hand. +func TestRemoteApplyGhDeclinedRunsNothingAndShowsTheStep(t *testing.T) { + for name, confirm := range map[string]tools.Confirm{ + "no": func(tools.Explanation) tools.Answer { return tools.Answer{Why: "answered no at the terminal"} }, + "no-one": nil, + "no-tty": func(tools.Explanation) tools.Answer { return tools.Answer{Why: "no terminal to ask at"} }, + } { + t.Run(name, func(t *testing.T) { + r := newGHOfferRig(t, bothEnabled) + res, err := RemoteApply(r.repo, confirmingPrompter{}, confirm) + if err != nil { + t.Fatal(err) + } + if len(r.calls) != 0 || r.ghRan(t) { + t.Fatalf("a no ran something: installs %v, gh ran %v", r.calls, r.ghRan(t)) + } + if res.Status != "refused" { + t.Fatalf("status = %q, want refused", res.Status) + } + notes := strings.Join(res.Notes, "\n") + for _, want := range []string{"gh not installed (", "install step (Homebrew): brew install gh", "the settings on GitHub stay unread and unchanged"} { + if !strings.Contains(notes, want) { + t.Errorf("the notes lack %q:\n%s", want, notes) + } + } + }) + } +} + +// TestRemoteApplyGhInstallFailureIsLoudAndStops: a failed step is reported with +// its output, and the verb goes no further: no gh call, no mirror written. +func TestRemoteApplyGhInstallFailureIsLoudAndStops(t *testing.T) { + r := newGHOfferRig(t, bothEnabled) + r.fail = true + res, err := RemoteApply(r.repo, confirmingPrompter{}, func(tools.Explanation) tools.Answer { return tools.Answer{Yes: true} }) + if err != nil { + t.Fatal(err) + } + if res.Status != "refused" || r.ghRan(t) || len(res.Writes) != 0 { + t.Fatalf("a failed install went on: status %q, gh ran %v, writes %v", res.Status, r.ghRan(t), res.Writes) + } + notes := strings.Join(res.Notes, "\n") + for _, want := range []string{"ran brew install gh — it failed", "download failed", "the settings on GitHub stay unread and unchanged"} { + if !strings.Contains(notes, want) { + t.Errorf("the notes lack %q:\n%s", want, notes) + } + } +} + +// TestRemoteReadNeverOffersGh: the read is bare ahoy's, which writes nothing, +// so a missing gh is explained and pointed at the verb that offers it, never +// installed. +func TestRemoteReadNeverOffersGh(t *testing.T) { + r := newGHOfferRig(t, bothEnabled) + res, err := RemoteRead(r.repo) + if err != nil { + t.Fatal(err) + } + if len(r.calls) != 0 { + t.Fatalf("the read ran an install: %v", r.calls) + } + notes := strings.Join(res.Notes, "\n") + for _, want := range []string{"brew install gh", "abcd ahoy remote apply offers to install it"} { + if !strings.Contains(notes, want) { + t.Errorf("the read's refusal lacks %q:\n%s", want, notes) + } + } +} diff --git a/internal/core/ahoy/remote_test.go b/internal/core/ahoy/remote_test.go index eba5cfc77..e7f435111 100644 --- a/internal/core/ahoy/remote_test.go +++ b/internal/core/ahoy/remote_test.go @@ -138,7 +138,7 @@ func TestRemoteApplyRequiresConfirmationNotJustInvocation(t *testing.T) { logPath := ghFake(t, bothDisabled) repo := managedRepoWithOrigin(t, "https://github.com/example-org/example-repo") - res, err := RemoteApply(repo, RefusingPrompter{}) + res, err := RemoteApply(repo, RefusingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -158,7 +158,7 @@ func TestRemoteApplyRequiresConfirmationNotJustInvocation(t *testing.T) { } // A nil prompter is the same refusal: a caller that forgot the seam must not // get an unconfirmed write by omission. - nilRes, err := RemoteApply(repo, nil) + nilRes, err := RemoteApply(repo, nil, nil) if err != nil { t.Fatal(err) } @@ -174,7 +174,7 @@ func TestRemoteApplyDoesNotAskWhenNothingWouldChange(t *testing.T) { ghFake(t, bothEnabled) repo := managedRepoWithOrigin(t, "https://github.com/example-org/example-repo") - res, err := RemoteApply(repo, RefusingPrompter{}) + res, err := RemoteApply(repo, RefusingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -193,7 +193,7 @@ func TestRemoteApplyEnablesSecretScanningBeforePushProtection(t *testing.T) { logPath := ghFake(t, bothDisabled) repo := managedRepoWithOrigin(t, "https://github.com/example-org/example-repo.git") - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -228,7 +228,7 @@ func TestRemoteApplyMirrorsTheDesiredState(t *testing.T) { ghFake(t, bothDisabled) repo := managedRepoWithOrigin(t, "git@github.com:example-org/example-repo.git") - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -285,7 +285,7 @@ func TestRemoteApplyIsIdempotent(t *testing.T) { // The FIRST run still writes the mirror — the tree does not yet record the // intent — but it changes no toggle, and it must take no remote write at all. - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -301,7 +301,7 @@ func TestRemoteApplyIsIdempotent(t *testing.T) { } } before := treeHash(t, repo) - res2, err := RemoteApply(repo, confirmingPrompter{}) + res2, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -326,7 +326,7 @@ func TestRemoteApplyHonoursTheOptOut(t *testing.T) { repo := managedRepoWithOrigin(t, "https://github.com/example-org/example-repo") writeNativeScanningOptOut(t, repo) - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -368,7 +368,7 @@ func TestRemoteApplyRefusesRatherThanGuess(t *testing.T) { setupHermetic(t) logPath := ghFake(t, bothDisabled) repo := managedRepoWithOrigin(t, "") - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -384,7 +384,7 @@ func TestRemoteApplyRefusesRatherThanGuess(t *testing.T) { setupHermetic(t) logPath := ghFake(t, bothDisabled) repo := managedRepoWithOrigin(t, "https://gitlab.example.com/example-org/example-repo.git") - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -402,7 +402,7 @@ func TestRemoteApplyRefusesRatherThanGuess(t *testing.T) { // A path segment that traverses would turn `repos/OWNER/REPO` into some other // endpoint entirely — on a verb whose whole job is a privileged write. repo := managedRepoWithOrigin(t, "https://github.com/../../user/repos.git") - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -429,7 +429,7 @@ func TestRemoteApplyRefusesRatherThanGuess(t *testing.T) { if err := os.MkdirAll(sub, 0o755); err != nil { t.Fatal(err) } - res, err := RemoteApply(sub, confirmingPrompter{}) + res, err := RemoteApply(sub, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -452,7 +452,7 @@ func TestRemoteApplyRefusesRatherThanGuess(t *testing.T) { if err := os.MkdirAll(sub, 0o755); err != nil { t.Fatal(err) } - if _, err := RemoteApply(sub, confirmingPrompter{}); err != nil { + if _, err := RemoteApply(sub, confirmingPrompter{}, nil); err != nil { t.Fatal(err) } if mirror(t, repo) == "" { @@ -467,7 +467,7 @@ func TestRemoteApplyRefusesRatherThanGuess(t *testing.T) { setupHermetic(t) logPath := ghFake(t, bothDisabled) repo := t.TempDir() - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -484,7 +484,7 @@ func TestRemoteApplyRefusesRatherThanGuess(t *testing.T) { logPath := ghFake(t, bothDisabled) t.Setenv("GH_FAKE_GET_FAIL", "1") repo := managedRepoWithOrigin(t, "https://github.com/example-org/example-repo") - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } @@ -511,7 +511,7 @@ func TestRemoteApplyStopsAtTheFirstFailedWrite(t *testing.T) { t.Setenv("GH_FAKE_PATCH_FAIL", `"secret_scanning":`) repo := managedRepoWithOrigin(t, "https://github.com/example-org/example-repo") - res, err := RemoteApply(repo, confirmingPrompter{}) + res, err := RemoteApply(repo, confirmingPrompter{}, nil) if err != nil { t.Fatal(err) } diff --git a/internal/core/site/setup.go b/internal/core/site/setup.go index a080b6df6..fcd1e8dd0 100644 --- a/internal/core/site/setup.go +++ b/internal/core/site/setup.go @@ -48,6 +48,7 @@ import ( "github.com/intentdriven/abcd/internal/core/credential" "github.com/intentdriven/abcd/internal/core/launch/scaffold" "github.com/intentdriven/abcd/internal/core/positioning" + "github.com/intentdriven/abcd/internal/core/tools" "github.com/intentdriven/abcd/internal/gitutil" ) @@ -134,6 +135,9 @@ type SetupRequest struct { Asker Asker // Forge is the repository's forge; nil resolves GitHub through gh. Forge Forge + // ConfirmTool answers the offer to install a missing gh when Forge is nil + // (the DQ3 ruling); nil asks no one, so nothing is installed. + ConfirmTool tools.Confirm // Credentials resolves the hosting credential by name; nil is this // machine's store. Credentials credential.Source @@ -210,18 +214,26 @@ func Setup(req SetupRequest) (SetupResult, error) { adapter = a } s := hosting.Site{Name: hostingBlock.Name, Domain: hostingBlock.Domain} - forge, forgeNote := req.Forge, "" + forge, forgeNote, offerNote := req.Forge, "", "" if forge == nil { f, ferr := GitHubForge(root) if ferr != nil { forgeNote = "the forge is not reachable from this checkout, so the environments were not created: " + ferr.Error() + } else if offer, ok := ahoy.OfferGH(root, req.ConfirmTool); !ok { + // gh is missing and was not installed: the forge is unreachable, + // and the note carries why and the command to run. + forgeNote = "the forge is not reachable from this checkout, so the environments were not created: " + strings.Join(offer, "\n") } else { forge = f + offerNote = strings.Join(offer, "\n") } } branch, branchNote := defaultBranch(ctx, root, forge) res := SetupResult{Host: HostOutcome{Provider: adapter.Name(), Name: s.Name, Domain: s.Domain, Status: HostNotReached}} + if offerNote != "" { + res.Notes = append(res.Notes, offerNote) + } if branchNote != "" { res.Notes = append(res.Notes, branchNote) } diff --git a/internal/core/site/setup_test.go b/internal/core/site/setup_test.go index 95e553982..a6cda5579 100644 --- a/internal/core/site/setup_test.go +++ b/internal/core/site/setup_test.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "os" + "os/exec" "path/filepath" "reflect" "regexp" @@ -22,6 +23,7 @@ import ( "github.com/intentdriven/abcd/internal/adapter/hosting/cloudflare/cloudflaretest" "github.com/intentdriven/abcd/internal/core/credential" "github.com/intentdriven/abcd/internal/core/launch/scaffold" + "github.com/intentdriven/abcd/internal/core/tools" "github.com/intentdriven/abcd/internal/gittest" ) @@ -967,3 +969,64 @@ func TestASiteWithNoDocsBuildLinksNoDocsTree(t *testing.T) { t.Error("a composition declaring its docs surface lost the header's Docs link or the docs route") } } + +// ghFreePath leaves git on PATH and nothing else, so neither gh nor a package +// manager is found and no install step can run from the test. +func ghFreePath(t *testing.T) { + t.Helper() + gitBin, err := exec.LookPath("git") + if err != nil { + t.Skip("git unavailable") + } + dir := t.TempDir() + if err := os.Symlink(gitBin, filepath.Join(dir, "git")); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + t.Setenv("CI", "") + t.Setenv("GITHUB_ACTIONS", "") +} + +// TestSetupOffersAMissingGh is the gh offer at site setup (the DQ3 ruling, +// itd-63 criterion 2): with gh missing the forge stage puts the install to the +// question with the registry's explanation. A no runs nothing and the note +// carries the command; a yes reaches the registry's step (here Homebrew is +// absent, so the step says so and nothing ran). Either way the forge stage is +// not reached and says what remains. +func TestSetupOffersAMissingGh(t *testing.T) { + for _, tc := range []struct { + name string + ans tools.Answer + want string + }{ + {"no", tools.Answer{Why: "answered no at the terminal"}, "gh not installed (answered no at the terminal)"}, + {"yes", tools.Answer{Yes: true}, "Homebrew (brew) is not on PATH, so the step cannot run"}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t) + ghFreePath(t) + var seen []string + res := h.run(t, func(r *SetupRequest) { + r.Forge = nil + r.ConfirmTool = func(e tools.Explanation) tools.Answer { + seen = append(seen, e.Tool+": "+e.StepText()) + return tc.ans + } + }) + if len(seen) != 1 || seen[0] != "gh: brew install gh" { + t.Fatalf("gh was not offered with its step: %v", seen) + } + notes := strings.Join(res.Notes, "\n") + for _, want := range []string{tc.want, "brew install gh"} { + if !strings.Contains(notes, want) { + t.Errorf("the notes lack %q:\n%s", want, notes) + } + } + for _, env := range res.Environments { + if env.Status != RemoteUnreachable { + t.Errorf("environment %s = %q with gh missing", env.Name, env.Status) + } + } + }) + } +} diff --git a/internal/surface/cli/ahoy_remote_gh_offer_test.go b/internal/surface/cli/ahoy_remote_gh_offer_test.go new file mode 100644 index 000000000..b98ab61aa --- /dev/null +++ b/internal/surface/cli/ahoy_remote_gh_offer_test.go @@ -0,0 +1,108 @@ +package cli + +import ( + "bufio" + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/ahoy" + "github.com/intentdriven/abcd/internal/core/tools" + "github.com/intentdriven/abcd/internal/gittest" +) + +// TestTerminalToolConfirmAsksOnlyAPersonAtATerminal is the gh offer's +// confirmation at the remote write (the DQ3 ruling): only an answer typed at +// a terminal is a yes. A piped y, --yes and a prompter that is no terminal all +// decline, and each decline carries the command to run by hand. The question +// shows the explanation and the exact step before anything runs. +func TestTerminalToolConfirmAsksOnlyAPersonAtATerminal(t *testing.T) { + e := tools.Explain("gh", tools.GitHubSettings) + var w bytes.Buffer + + piped := &stdinPrompter{r: bufio.NewReader(strings.NewReader("y\n")), w: &w} + if ans := terminalToolConfirm(piped, false, &w)(e); ans.Yes || + !strings.Contains(ans.Why, "no terminal to ask at") || !strings.Contains(ans.Why, "brew install gh") { + t.Fatalf("a piped y: %+v", ans) + } + tty := &stdinPrompter{r: bufio.NewReader(strings.NewReader("y\n")), w: &w, tty: true} + if ans := terminalToolConfirm(tty, true, &w)(e); ans.Yes || + !strings.Contains(ans.Why, "--yes") || !strings.Contains(ans.Why, "brew install gh") { + t.Fatalf("--yes at a terminal: %+v", ans) + } + if ans := terminalToolConfirm(ahoy.RefusingPrompter{}, false, &w)(e); ans.Yes { + t.Fatal("the refusing prompter installed gh") + } + if w.Len() != 0 { + t.Fatalf("a decline asked a question anyway:\n%s", w.String()) + } + + tty = &stdinPrompter{r: bufio.NewReader(strings.NewReader("n\n")), w: &w, tty: true} + if ans := terminalToolConfirm(tty, false, &w)(e); ans.Yes { + t.Fatal("an n typed at a terminal was a yes") + } + w.Reset() + tty = &stdinPrompter{r: bufio.NewReader(strings.NewReader("y\n")), w: &w, tty: true} + ans := terminalToolConfirm(tty, false, &w)(e) + if !ans.Yes { + t.Fatalf("a y typed at a terminal was not a yes: %+v", ans) + } + asked := w.String() + q := strings.Index(asked, "Install gh now by running brew install gh? [y/N]") + if q < 0 { + t.Fatalf("the question does not show the exact step:\n%s", asked) + } + for _, want := range []string{"what abcd uses it for:", "without it:", "gh auth login"} { + if i := strings.Index(asked, want); i < 0 || i > q { + t.Errorf("%q is not shown before the question:\n%s", want, asked) + } + } + if !strings.Contains(asked[q:], "running brew install gh") { + t.Errorf("the step is not announced as it starts:\n%s", asked) + } +} + +// TestAhoyRemoteApplyNeverInstallsGhOnAScriptedYes is the end-to-end no: a +// piped y and --yes each reach the gh offer and decline it, so the step is +// never attempted (brew's absence would otherwise be the reason), and the +// refusal carries the command to run. +func TestAhoyRemoteApplyNeverInstallsGhOnAScriptedYes(t *testing.T) { + hermeticEnv(t) + repo := gittest.NewRepo(t) + repo.Git("remote", "add", "origin", "https://github.com/example-org/example-repo.git") + t.Chdir(repo.Root()) + if _, err := runCLIErr(t, "ahoy", "install", "--yes", "--adopt", + "--visibility", "private", "--docs-target", "both", + "--oracle-backend", "host-delegated", "--scan-deep", "false"); err != nil { + t.Fatalf("install: %v", err) + } + toolFreePath(t) + + for _, tc := range []struct { + args []string + why string + }{ + {[]string{"ahoy", "remote", "apply", "--json"}, "no terminal to ask at"}, + {[]string{"ahoy", "remote", "apply", "--yes", "--json"}, "--yes"}, + } { + out, errOut, err := runCLIPipedStdinSplit(t, "y\ny\n", tc.args...) + if err == nil { + t.Fatalf("%v exited zero with gh missing:\n%s", tc.args, out) + } + var res ahoy.RemoteResult + if jerr := json.Unmarshal(out, &res); jerr != nil { + t.Fatalf("%v: not JSON: %v\n%s\n%s", tc.args, jerr, out, errOut) + } + notes := strings.Join(res.Notes, "\n") + if res.Status != "refused" || !strings.Contains(notes, tc.why) || !strings.Contains(notes, "brew install gh") { + t.Errorf("%v: status %q, notes lack %q or the step:\n%s", tc.args, res.Status, tc.why, notes) + } + if strings.Contains(notes, "is not on PATH, so the step cannot run") { + t.Errorf("%v: a scripted yes reached the install step:\n%s", tc.args, notes) + } + if strings.Contains(string(errOut), "[y/N]") { + t.Errorf("%v: the install was asked of a pipe:\n%s", tc.args, errOut) + } + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 4d921ff2b..971562625 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3689,11 +3689,17 @@ func newAhoyRemoteCommand(asJSON *bool) *cobra.Command { // adr-44 / invariant 10: the remote write is CONFIRMED as well as // invoked. An unanswered run declines, so a script that pipes nothing // changes nothing; --yes is the explicit way to say yes in advance. + // + // A missing gh is offered for install only to a person at a + // terminal: --yes answers the settings change, never the install + // of a program (the DQ3 ruling), so the offer's confirmation is + // built from the prompter before --yes replaces it. p := newPrompter(cmd) + confirmTool := terminalToolConfirm(p, remoteYes, cmd.ErrOrStderr()) if remoteYes { p = alwaysConfirm{} } - res, err := ahoy.RemoteApply(cwd, p) + res, err := ahoy.RemoteApply(cwd, p, confirmTool) if err != nil { return err } @@ -3715,7 +3721,7 @@ func newAhoyRemoteCommand(asJSON *bool) *cobra.Command { return nil }, } - applyCmd.Flags().BoolVar(&remoteYes, "yes", false, "confirm the remote change without being asked; without it an unanswered run declines and changes nothing") + applyCmd.Flags().BoolVar(&remoteYes, "yes", false, "confirm the remote change without being asked (never the install of a missing gh); without it an unanswered run declines and changes nothing") remoteCmd.AddCommand(applyCmd) return remoteCmd } @@ -3868,16 +3874,36 @@ func installToolNames(names []string) (map[string]bool, error) { // --install-tool, which is how a host relays the answer its own question tool // got. --yes never installs a tool. Every no carries the way to say yes. func toolConfirm(p ahoy.Prompter, named map[string]bool, yes bool, w io.Writer) tools.Confirm { + ask := askToolAtTerminal(p, yes, w, func(e tools.Explanation) string { return "name it with --install-tool " + e.Tool }) return func(e tools.Explanation) tools.Answer { if named[e.Tool] { return tools.Answer{Yes: true, Why: "named with --install-tool"} } + return ask(e) + } +} + +// terminalToolConfirm is the install question at a verb with no +// --install-tool: the gh offer at ahoy remote apply (the product thinker's +// DQ3 ruling, 2026-09-29). Its only yes is one typed at a terminal; --yes, a +// piped stream and a caller with no terminal each decline, carrying the +// command the person can run themselves. +func terminalToolConfirm(p ahoy.Prompter, yes bool, w io.Writer) tools.Confirm { + return askToolAtTerminal(p, yes, w, func(e tools.Explanation) string { return "install it yourself with " + e.StepText() }) +} + +// askToolAtTerminal asks the install question of a person at a terminal and +// declines everywhere else. otherwise names the other way to a yes, for every +// decline to carry. The explanation and the exact step are shown before the +// question, and the step is announced as it starts. +func askToolAtTerminal(p ahoy.Prompter, yes bool, w io.Writer, otherwise func(tools.Explanation) string) tools.Confirm { + return func(e tools.Explanation) tools.Answer { if yes { - return tools.Answer{Why: "--yes never installs a tool; name it with --install-tool " + e.Tool + ", or run without --yes at a terminal"} + return tools.Answer{Why: "--yes never installs a tool; " + otherwise(e) + ", or run without --yes at a terminal"} } sp, ok := p.(*stdinPrompter) if !ok || !sp.tty { - return tools.Answer{Why: "no terminal to ask at: abcd installs a tool only on an answer typed at a terminal, or with --install-tool " + e.Tool} + return tools.Answer{Why: "no terminal to ask at: abcd installs a tool only on an answer typed at a terminal; " + otherwise(e)} } for _, line := range e.Lines() { fmt.Fprintln(w, termsafe.Sanitize(line)) diff --git a/internal/surface/cli/site.go b/internal/surface/cli/site.go index dc4755d5c..76f5ab9a4 100644 --- a/internal/surface/cli/site.go +++ b/internal/surface/cli/site.go @@ -284,13 +284,18 @@ func newSiteSetupCommand(asJSON *bool) *cobra.Command { if err != nil { return err } - var asker site.Asker = newPrompter(cmd) + // A missing gh is offered for install only to a person at a + // terminal, never on --yes (the DQ3 ruling), so its confirmation is + // built from the prompter before --yes replaces it. + p := newPrompter(cmd) + confirmTool := terminalToolConfirm(p, yes, cmd.ErrOrStderr()) + var asker site.Asker = p if yes { asker = alwaysConfirm{} } res, err := site.Setup(site.SetupRequest{ RepoRoot: cwd, Name: name, Domain: domain, Confirm: confirm, Asker: asker, - Context: cmd.Context(), + ConfirmTool: confirmTool, Context: cmd.Context(), }) if err != nil { return &exitError{Code: 2, Msg: "abcd site setup: " + scrubPaths(err)} @@ -312,7 +317,7 @@ func newSiteSetupCommand(asJSON *bool) *cobra.Command { cmd.Flags().StringVar(&name, "name", "", "host name when the composition names none (default: the repository's name)") cmd.Flags().StringVar(&domain, "domain", "", "custom domain to route to the host when the composition names none") cmd.Flags().BoolVar(&confirm, "confirm", false, "replace a workflow or host configuration that differs from what setup writes") - cmd.Flags().BoolVar(&yes, "yes", false, "confirm the forge and host changes without being asked; without it an unanswered run declines them") + cmd.Flags().BoolVar(&yes, "yes", false, "confirm the forge and host changes without being asked (never the install of a missing gh); without it an unanswered run declines them") return cmd } From 3802c4971145f8ed388856df4cc08adf1866ce5b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:03:03 +0100 Subject: [PATCH 42/69] =?UTF-8?q?chore:=20resolve=20iss-2609281911024838?= =?UTF-8?q?=20=E2=80=94=20ahoy=20offers=20to=20install=20gh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DQ3 ruling settles the question this record was deferred on, and e9a393675 delivers it. itd-63 gains an Audit Notes line under ruling H10 that records how criterion 2 reads for gh: the explicit yes is one typed at a terminal. The criterion text is unchanged. Resolves: iss-2609281911024838 Assisted-by: Claude:claude-opus-5-5 --- .../shipped/itd-63-setup-wizard-explains-installs.md | 2 ++ ...riterion-2-diverges-for-gh-the-explain-then-install.md | 8 ++++++++ 2 files changed, 10 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609281911024838-itd-63-criterion-2-diverges-for-gh-the-explain-then-install.md (70%) diff --git a/.abcd/development/intents/shipped/itd-63-setup-wizard-explains-installs.md b/.abcd/development/intents/shipped/itd-63-setup-wizard-explains-installs.md index d8180ad1c..6d6dd581e 100644 --- a/.abcd/development/intents/shipped/itd-63-setup-wizard-explains-installs.md +++ b/.abcd/development/intents/shipped/itd-63-setup-wizard-explains-installs.md @@ -141,6 +141,8 @@ Gap audit: evidence: .abcd/development/intents/drafts/itd-62-pluggable-safety-gate.md:1 — "drafts/" <!-- abcd-review-end receipt=rcp-3c9fb4ba9770 --> +Criterion 2 read on 2026-09-30 for gh, recorded as iss-2609281911024838 under the product thinker's DQ3 ruling of 2026-09-29 (offer to install gh on an explicit yes) and ruling H10: the install half covers gh as well as gitleaks. `ahoy remote apply` and `site setup` explain a missing gh from the registry and offer the registry's step, and for gh the explicit yes is one typed at a terminal: `--yes`, a piped answer and a run with no terminal each decline, and the refusal carries the command. The read, `ahoy --remote`, writes nothing and never offers the install; it names the apply. `--install-tool` still names gitleaks alone. TestRemoteApplyOffersGhAndRunsTheStepOnYes, TestRemoteApplyGhDeclinedRunsNothingAndShowsTheStep, TestSetupOffersAMissingGh and TestTerminalToolConfirmAsksOnlyAPersonAtATerminal pin it. The criterion text above stands as shipped. + ### Linkage note (spc-83.5) Ships as one of FOUR intents sharing spec diff --git a/.abcd/work/issues/open/iss-2609281911024838-itd-63-criterion-2-diverges-for-gh-the-explain-then-install.md b/.abcd/work/issues/resolved/iss-2609281911024838-itd-63-criterion-2-diverges-for-gh-the-explain-then-install.md similarity index 70% rename from .abcd/work/issues/open/iss-2609281911024838-itd-63-criterion-2-diverges-for-gh-the-explain-then-install.md rename to .abcd/work/issues/resolved/iss-2609281911024838-itd-63-criterion-2-diverges-for-gh-the-explain-then-install.md index 6d5bfc978..0e61ab4d7 100644 --- a/.abcd/work/issues/open/iss-2609281911024838-itd-63-criterion-2-diverges-for-gh-the-explain-then-install.md +++ b/.abcd/work/issues/resolved/iss-2609281911024838-itd-63-criterion-2-diverges-for-gh-the-explain-then-install.md @@ -12,6 +12,10 @@ found_at: "internal/core/ahoy/detect.go" deferred_after: "v0.11.1" deferral_reason: "ruling owed to the product thinker: should abcd offer to install GitHub's command-line tool itself, on an explicit yes, when ahoy remote or site setup needs it, or does itd-63 say the install half covers gitleaks only? The first is a lane: both verbs route their missing-gh refusal through tools.Install with the host-relayed yes." remedy: "Waits on the itd-63 gh ruling: if offered: ahoy remote and site setup route a missing gh through tools.Install with the host-relayed yes (gh offered only when a verb that needs it runs, by the registry's Homebrew step) and installToolNames accepts gh, proven by tests that nothing installs without the yes and that the yes runs the install; if narrowed: record the narrowing in the H10 shape, never rewriting criterion 2: this issue resolved by the lane's commit plus an Audit Notes line on itd-63 saying the install half covers gitleaks only." +resolution: "Resolved on the DQ3 ruling (offer to install gh on an explicit yes): ahoy remote apply and site setup explain a missing gh and offer the registry's Homebrew step through ahoy.OfferGH. The step runs only on a yes typed at a terminal. --yes, a piped answer and a run with no terminal decline, and the refusal carries the command. A failed or unverified install refuses before any request. ahoy --remote never installs and names the apply instead. --install-tool still names gitleaks alone. itd-63 gains the criterion-2 Audit Notes line under ruling H10." +impact: additive +resolved_by: + commit: "e9a393675" --- itd-63 criterion 2 diverges for gh: the explain-then-install mode's install half is offered for gitleaks alone. ahoy.DependencyTools is {gitleaks} (internal/core/ahoy/detect.go), so ahoy install never puts gh to the question and --install-tool gh is refused by installToolNames (internal/surface/cli/cli.go), while ahoy remote and site setup refuse a missing gh with tools.Missing (internal/core/ahoy/remote.go) and show the Homebrew step the person must then run by hand. A required tool the registry knows how to install is explained but never installed on a yes, so for gh the criterion's 'the install runs only on an explicit yes' never arises. Either a gh dependency gap joins DependencyTools (offered only when a verb that needs gh is in play), or the record states that the mode installs gitleaks only. @@ -19,3 +23,7 @@ itd-63 criterion 2 diverges for gh: the explain-then-install mode's install half ## Remedy grounds (2026-09-29) SOTA check: the gh project names Homebrew as its recommended macOS route (https://github.com/cli/cli/blob/trunk/docs/install_macos.md, read 2026-09-29), which the registry already explains; mise refuses to act on a project's config until the person trusts it (https://mise.jdx.dev/cli/trust.html, read 2026-09-29), the same consent-before-install shape as the host-relayed yes. Rejected for fit: adopting mise or devbox as the installer, a new dependency the repository has not signed off. + +## Grounds + +- pursued: a person at a terminal who meets a missing gh at ahoy remote apply or site setup is asked, and on yes gh is installed and the verb goes on. TestRemoteApplyOffersGhAndRunsTheStepOnYes and TestSetupOffersAMissingGh show it, and TestAhoyRemoteApplyNeverInstallsGhOnAScriptedYes shows a scripted yes installs nothing. It is shown wrong if a --yes or piped run ever reaches the install step, or a yes at a terminal still leaves the verb refusing with gh uninstalled. From dd7f6a76b9b668ade7f6a45113c269407808808f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:05:41 +0100 Subject: [PATCH 43/69] fix(status): the board's next-up head pays build next's peers check Ruling CC1 of 2026-09-29: the bare board pays the peers read, so "next up" always equals the intent `abcd build next` would pick. The block takes a statusblock.PeerReader beside its LaneReader, and the board hands in loop.StatusPeers, which is the pick's own readPeers and peersCheck read once for the block: one check, no second copy. The read happens only when a head is in reach, so a record with nothing to start pays nothing. A peer the listing cannot read holds every record for the head as for the pick, so neither names one; a fault reading the peers omits the block with the reason on stderr, the board's standing contract, where it refuses the pick. The site's Status page hands in no PeerReader: another checkout's holdings are this machine's state and never a published page's. The board chapter, the site chapter and commands/abcd.md say so. Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/08-abcd.md | 15 ++- .../development/brief/04-surfaces/22-site.md | 4 +- commands/abcd.md | 5 +- internal/core/implement/loop/loop.go | 20 ++++ internal/core/implement/loop/status_test.go | 79 +++++++++++++++- internal/core/intent/startcheck.go | 5 +- internal/core/site/build.go | 2 +- internal/core/site/status_test.go | 2 +- internal/core/statusblock/statusblock.go | 55 ++++++++--- internal/core/statusblock/statusblock_test.go | 94 ++++++++++++++++--- internal/surface/cli/board_status.go | 2 +- 11 files changed, 245 insertions(+), 38 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index 7f7a4dd23..fb1583e74 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -234,8 +234,13 @@ passes the build's record-only pre-start checks, read through the one statement of them the build runs too (`intent.StartChecksIn`: no open question, the claim sections answered, no hold, no unshipped blocker, a step left to build), and not one the state file shows in a lane, as the pick passes over an -intent with a run in progress. The build's peers check is not run: the block -reads no other checkout, so an intent a peer holds can still be the head. The +intent with a run in progress. Nor is it one another checkout holds: the +board pays the build's peers check (`loop.StatusPeers`, the check build next +runs; ruling CC1 of 2026-09-29), read once and only when a head is in reach, +so the head is always the intent the pick would choose. A peer the listing +names and cannot read holds every record for the head as for the pick, so +neither names one. The site's Status page reads no other checkout: another +checkout's holdings are this machine's state, never a published page's. The block's `order` field names that order (`pick`). The text render is a `status:` heading with the three counts, then `Now:` and `Next:`, one line per intent: its id, its title, and in brackets its lane state @@ -247,8 +252,10 @@ carries a `status` object with `now`, `next` and `later` in full, each row they apply, and `order`. The block is present in a repository abcd manages and absent elsewhere, and a record that cannot be read omits it with the reason on stderr. The read is `internal/core/statusblock`, the one the site's Status -page renders too ([`22-site.md`](22-site.md#the-page-set)); the state file is -read through the implement loop (`loop.StatusLanes`). +page renders too ([`22-site.md`](22-site.md#the-page-set)); the state file and +the peers are read through the implement loop (`loop.StatusLanes`, +`loop.StatusPeers`), and a fault reading the peers omits the block with the +reason on stderr, as it refuses build next. ## The board itself is not built diff --git a/.abcd/development/brief/04-surfaces/22-site.md b/.abcd/development/brief/04-surfaces/22-site.md index dad329c4d..18277ce38 100644 --- a/.abcd/development/brief/04-surfaces/22-site.md +++ b/.abcd/development/brief/04-surfaces/22-site.md @@ -107,7 +107,9 @@ its record page, its title, and what places it there, with Next and the head in the pick order the board reads them in. The site build reads the implement loop's state file for Now's lane rows through the reader its front door hands it, the loop's own, and a build with no state file, as a release -build has, shows Now as the head alone. +build has, shows Now as the head alone. The site build hands in no peers +check, so its head, unlike the board's, does not pass over an intent another +checkout on the building machine holds. The documentation tree under `/docs/` is not among these pages: the docs build writes it beside them. The composition declaration's `docs` block says it is diff --git a/commands/abcd.md b/commands/abcd.md index 9d04bfd07..b9fcb85ae 100644 --- a/commands/abcd.md +++ b/commands/abcd.md @@ -90,8 +90,9 @@ in `abcd build next`'s pick order (`order` is `pick`): the readiest first by the pick's score, the oldest among equals, and the head passes over an intent that `abcd build next` refuses from the record alone (an open question, an unanswered claim section, a hold, an unshipped blocker, no step left to build) -or that is already in a lane. The head does not consult other checkouts, so an -intent a peer holds can still be marked `next_up`. Relay Now first: it is what is being built and +or that is already in a lane, or that another checkout holds (build next's +peers check), so `next_up` is always the intent `abcd build next` would pick. +Relay Now first: it is what is being built and what comes next. The block is computed each time and nothing stores it. ## Record-id dispatch diff --git a/internal/core/implement/loop/loop.go b/internal/core/implement/loop/loop.go index fab31f16c..11d6ad511 100644 --- a/internal/core/implement/loop/loop.go +++ b/internal/core/implement/loop/loop.go @@ -904,3 +904,23 @@ func StatusLanes(repoRoot string) ([]statusblock.Started, error) { } return out, nil } + +// StatusPeers is the peers read the status block's head takes (ruling CC1 of +// 2026-09-29): build next's own peers check, read once for the block and +// judged per intent, so the board's "next up" passes over exactly the intents +// another checkout holds that the pick passes over. It judges on behalf of no +// session, so every live claim is a peer's. A peer the listing cannot read +// fails closed on each record, as it does for the pick. It is a +// statusblock.PeerReader. +func StatusPeers(repoRoot string) (statusblock.HeldBy, error) { + snap, err := readPeers(repoRoot) + if err != nil { + return nil, err + } + return func(r intent.ReadyResult) string { + if row := peersCheck(r, "", snap); !row.OK { + return row.Detail + } + return "" + }, nil +} diff --git a/internal/core/implement/loop/status_test.go b/internal/core/implement/loop/status_test.go index 6c82f2673..ea231f0e8 100644 --- a/internal/core/implement/loop/status_test.go +++ b/internal/core/implement/loop/status_test.go @@ -98,7 +98,7 @@ func TestTheStatusHeadIsTheIntentBuildNextPicks(t *testing.T) { if !ok { t.Fatalf("running=%v: no candidate: %+v", running, set) } - b, err := statusblock.Read(repo.Root(), StatusLanes) + b, err := statusblock.Read(repo.Root(), StatusLanes, StatusPeers) if err != nil { t.Fatal(err) } @@ -156,7 +156,7 @@ func TestTheStatusHeadPassesOverWhatBuildNextExcludesFromTheRecord(t *testing.T) t.Fatalf("precondition: %s is excluded by %q, got %+v", e.ID, want[e.ID], e) } } - b, err := statusblock.Read(repo.Root(), StatusLanes) + b, err := statusblock.Read(repo.Root(), StatusLanes, StatusPeers) if err != nil { t.Fatal(err) } @@ -170,3 +170,78 @@ func TestTheStatusHeadPassesOverWhatBuildNextExcludesFromTheRecord(t *testing.T) t.Errorf("the head is %q, build next picks %q", head, pick.Chosen.ID) } } + +// TestTheStatusHeadPassesOverAnIntentAPeerHolds (ruling CC1): the bare board +// pays build next's peers read, so "next up" always equals the pick. Another +// checkout holding the readiest intent in another bucket excludes it from the +// pick, and the head passes over it exactly as the pick does; a peer the +// listing names and cannot read fails closed on every record, for the head as +// for the pick, so neither names an intent. +func TestTheStatusHeadPassesOverAnIntentAPeerHolds(t *testing.T) { + records := map[string][2]string{ + "20": {pickIntent("20", "", settledQuestions, gwt), pickSpec("20", "")}, + "21": {pickIntent("21", "", settledQuestions, gwt), pickSpec("21", fpSmall)}, + } + head := func(b statusblock.Block) string { + for _, r := range b.Now { + if r.NextUp { + return r.ID + } + } + return "" + } + + t.Run("a branch holds the readiest intent", func(t *testing.T) { + repo := pickRepo(t, records) + ir, _ := pickRel("21") + repo.Git("checkout", "-q", "-b", "lane-alpha") + repo.Remove(ir) + repo.Write(".abcd/development/intents/shipped/itd-21-i21.md", records["21"][0]) + repo.Commit("deliver itd-21") + repo.Git("checkout", "-q", "main") + + set, err := candidates(repo.Root(), "") + if err != nil { + t.Fatal(err) + } + pick, ok := intent.Choose(set.Candidates) + if !ok || pick.Chosen.ID != "itd-20" { + t.Fatalf("precondition: build next passes over itd-21, which lane-alpha holds, and picks itd-20: %+v", set) + } + b, err := statusblock.Read(repo.Root(), StatusLanes, StatusPeers) + if err != nil { + t.Fatal(err) + } + if got := head(b); got != pick.Chosen.ID { + t.Errorf("the head is %q, build next picks %q: the board must pay the pick's peers read", got, pick.Chosen.ID) + } + }) + + t.Run("a peer cannot be read", func(t *testing.T) { + repo := pickRepo(t, records) + repo.Git("checkout", "-q", "-b", "lane-beta") + beta := "---\nid: itd-30\nslug: beta\n---\n# beta\n" + repo.Write(".abcd/development/intents/drafts/itd-30-beta.md", beta) + repo.Write(".abcd/development/intents/planned/itd-30-beta.md", beta) + repo.Commit("split beta") + repo.Git("checkout", "-q", "main") + + set, err := candidates(repo.Root(), "") + if err != nil { + t.Fatal(err) + } + if len(set.Candidates) != 0 { + t.Fatalf("precondition: an unreadable peer leaves the pick no candidate: %+v", set) + } + b, err := statusblock.Read(repo.Root(), StatusLanes, StatusPeers) + if err != nil { + t.Fatalf("an unreadable peer fails closed on each record, never the board: %v", err) + } + if got := head(b); got != "" { + t.Errorf("the head is %q; the pick has no candidate, so nothing is next up", got) + } + if len(b.Next) != 2 { + t.Errorf("Next = %+v, want both READY intents still listed", b.Next) + } + }) +} diff --git a/internal/core/intent/startcheck.go b/internal/core/intent/startcheck.go index 4d08c2e48..ac6aa7a77 100644 --- a/internal/core/intent/startcheck.go +++ b/internal/core/intent/startcheck.go @@ -7,8 +7,9 @@ package intent // sections, the hold, the blockers and the spec's steps. It is the one // statement of them, so the build (loop.Check) and the status board's "next // up" (statusblock.Read) exclude the same intents for the same reasons. The -// check that reads other checkouts, the peers, stays with the build: the board -// does not consult them. +// check that reads other checkouts, the peers, stays with the build, and the +// board reaches it through the build's own reader (loop.StatusPeers, ruling +// CC1). import ( "fmt" diff --git a/internal/core/site/build.go b/internal/core/site/build.go index 83e2232c6..6d308f152 100644 --- a/internal/core/site/build.go +++ b/internal/core/site/build.go @@ -468,7 +468,7 @@ func Build(req Request) (Result, error) { return Result{}, err } if ex.pages.status { - block, err := statusblock.Read(repoRoot, req.Lanes) + block, err := statusblock.Read(repoRoot, req.Lanes, nil) if err != nil { return Result{}, err } diff --git a/internal/core/site/status_test.go b/internal/core/site/status_test.go index e31f96a7f..f24da04e6 100644 --- a/internal/core/site/status_test.go +++ b/internal/core/site/status_test.go @@ -28,7 +28,7 @@ func TestStatusPageRendersTheBlockFromTheSameRead(t *testing.T) { } page := outFile(t, out, "record/health/index.html") - want, err := statusblock.Read(f.Root(), lanes) + want, err := statusblock.Read(f.Root(), lanes, nil) if err != nil { t.Fatal(err) } diff --git a/internal/core/statusblock/statusblock.go b/internal/core/statusblock/statusblock.go index 511a555c6..ce881d5c4 100644 --- a/internal/core/statusblock/statusblock.go +++ b/internal/core/statusblock/statusblock.go @@ -8,8 +8,10 @@ // - Now is every intent the build's state file shows in a lane, with its lane // state, then the head marked "next up": the first READY intent in pick // order that the build's record-only pre-start checks -// (intent.StartChecksIn) let start and that is in no lane. Now is empty -// only when no READY intent passes them. +// (intent.StartChecksIn) let start, that is in no lane, and that no peer +// holds when the caller hands in the build's peers check (ruling CC1 of +// 2026-09-29: the bare board pays that read, so its "next up" is the +// pick). Now is empty only when no READY intent passes them. // - Next is every planned intent the readiness gate reports READY and the // state file shows in no lane, in pick order: `abcd build next`'s one // order (intent.PickLess), each intent scored by the read the pick scores @@ -27,10 +29,14 @@ // returns each such intent to the list the gate places it in, and leaves a // head (criterion 3). // -// The package reads the state file through a LaneReader its caller supplies -// rather than importing the implement loop: the loop's own imports reach the -// site renderer, which renders this block, so the reader is handed in by the -// front door (the loop's StatusLanes) and both surfaces call the one Read. +// The package reads the state file through a LaneReader, and the peers through +// a PeerReader, its caller supplies rather than importing the implement loop: +// the loop's own imports reach the site renderer, which renders this block, so +// each reader is handed in by the front door (the loop's StatusLanes and +// StatusPeers) and both surfaces call the one Read. The bare board hands in +// both; the site's Status page hands in no PeerReader, because another +// checkout's holdings are this machine's local state and never a published +// page's. // // Core never writes to stdout; the front doors format the Block. package statusblock @@ -98,9 +104,20 @@ type Started struct { // absent state file reads as no lanes, never as an error. type LaneReader func(repoRoot string) ([]Started, error) +// HeldBy reports whether a peer holds the intent a readiness result judges, +// and why: the build's peers check, read once for the whole block. A non-empty +// reason is a holding. +type HeldBy func(r intent.ReadyResult) string + +// PeerReader reads the peers of the checkout at repoRoot once and returns the +// check the head is judged by. It is the build's own peers check (the loop's +// StatusPeers), so the head passes over exactly the intents the pick does. +type PeerReader func(repoRoot string) (HeldBy, error) + // Read computes the block for the checkout at repoRoot. lanes may be nil, which -// reads as an absent state file. It writes nothing. -func Read(repoRoot string, lanes LaneReader) (Block, error) { +// reads as an absent state file; peers may be nil, which leaves the head +// unjudged against other checkouts. It writes nothing. +func Read(repoRoot string, lanes LaneReader, peers PeerReader) (Block, error) { b := Block{Now: []Row{}, Next: []Row{}, Later: []Row{}, Order: OrderPick} corpus, err := intent.Load(repoRoot) @@ -158,6 +175,7 @@ func Read(repoRoot string, lanes LaneReader) (Block, error) { type ready struct { it intent.Intent row Row + res intent.ReadyResult cand intent.PickCandidate startable bool } @@ -181,7 +199,7 @@ func Read(repoRoot string, lanes LaneReader) (Block, error) { if err != nil { return Block{}, fmt.Errorf("reading the pre-start checks of %s: %w", it.ID, err) } - readies = append(readies, ready{it: it, row: r, cand: intent.PickCandidate{ID: it.ID, Score: score}, startable: chk.OK()}) + readies = append(readies, ready{it: it, row: r, res: res, cand: intent.PickCandidate{ID: it.ID, Score: score}, startable: chk.OK()}) continue } for _, c := range res.Checks { @@ -194,15 +212,28 @@ func Read(repoRoot string, lanes LaneReader) (Block, error) { sort.SliceStable(readies, func(i, j int) bool { return intent.PickLess(readies[i].cand, readies[j].cand) }) var head *Row + var heldBy HeldBy + peersRead := false for _, rd := range readies { b.Next = append(b.Next, rd.row) // The head is the first READY intent in pick order the build would // start: not one its record-only pre-start checks refuse (an open // question, an unanswered claim section, a hold, an unshipped blocker, - // no step left to build); one already in a lane is not in readies at - // all. The build's peers check is not run: the block does not consult - // other checkouts. + // no step left to build), nor one the build's peers check finds another + // checkout holding when the caller handed that check in; one already + // in a lane is not in readies at all. if head == nil && rd.startable { + // The peers are read once, and only when a head is in reach: a + // board with nothing to start pays no read of other checkouts. + if peers != nil && !peersRead { + peersRead = true + if heldBy, err = peers(repoRoot); err != nil { + return Block{}, fmt.Errorf("reading the peers for the next-up head: %w", err) + } + } + if heldBy != nil && heldBy(rd.res) != "" { + continue + } h := rd.row h.NextUp = true head = &h diff --git a/internal/core/statusblock/statusblock_test.go b/internal/core/statusblock/statusblock_test.go index ffcf22f11..3950b990a 100644 --- a/internal/core/statusblock/statusblock_test.go +++ b/internal/core/statusblock/statusblock_test.go @@ -2,6 +2,7 @@ package statusblock import ( "encoding/json" + "errors" "os" "path/filepath" "reflect" @@ -87,7 +88,7 @@ func lanesOf(started ...Started) LaneReader { func TestBlockPlacesEveryIntent(t *testing.T) { root := store(t) lane := Lane{Run: "run-2609290000000001", Lane: "lane-1", Step: "implement", Awaiting: "implementer"} - b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lane: lane})) + b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lane: lane}), nil) if err != nil { t.Fatal(err) } @@ -133,12 +134,12 @@ func TestBlockPlacesEveryIntent(t *testing.T) { // Next and Later are otherwise exactly what they were. func TestBlockWithoutAStateFileKeepsOnlyTheHead(t *testing.T) { root := store(t) - with, err := Read(root, lanesOf(Started{Intent: "itd-7", Lane: Lane{Run: "run-1", Lane: "lane-1", Step: "brief"}})) + with, err := Read(root, lanesOf(Started{Intent: "itd-7", Lane: Lane{Run: "run-1", Lane: "lane-1", Step: "brief"}}), nil) if err != nil { t.Fatal(err) } for name, reader := range map[string]LaneReader{"nil reader": nil, "no lanes": lanesOf()} { - without, err := Read(root, reader) + without, err := Read(root, reader, nil) if err != nil { t.Fatal(err) } @@ -173,7 +174,7 @@ func TestAnIntentInALaneIsOnlyUnderNow(t *testing.T) { for _, id := range inLane { started = append(started, Started{Intent: id, Lane: lane}) } - b, err := Read(root, lanesOf(started...)) + b, err := Read(root, lanesOf(started...), nil) if err != nil { t.Fatal(err) } @@ -192,7 +193,7 @@ func TestAnIntentInALaneIsOnlyUnderNow(t *testing.T) { // name, each row with its id and title, the lane state and the failing checks. func TestBlockJSONCarriesTheThreeLists(t *testing.T) { root := store(t) - b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lane: Lane{Run: "run-1", Lane: "lane-2", Step: "validate", Awaiting: "validator"}})) + b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lane: Lane{Run: "run-1", Lane: "lane-2", Step: "validate", Awaiting: "validator"}}), nil) if err != nil { t.Fatal(err) } @@ -215,7 +216,7 @@ func TestBlockJSONCarriesTheThreeLists(t *testing.T) { // TestBlockOnAnEmptyStoreHasEmptyLists: a record with no intents renders three // empty lists, never null ones, and no head. func TestBlockOnAnEmptyStoreHasEmptyLists(t *testing.T) { - b, err := Read(t.TempDir(), nil) + b, err := Read(t.TempDir(), nil, nil) if err != nil { t.Fatal(err) } @@ -249,7 +250,7 @@ func TestTheHeadSkipsAHeldIntent(t *testing.T) { w(in+"itd-3-held.md", readyIntent("itd-3", "The held one", "spc-13", "held: \"awaiting a ruling\"\n")) w(sp+"spc-13-held.md", writtenSpec("spc-13", "itd-3")) - b, err := Read(root, nil) + b, err := Read(root, nil, nil) if err != nil { t.Fatal(err) } @@ -262,7 +263,7 @@ func TestTheHeadSkipsAHeldIntent(t *testing.T) { w(in+"itd-4-free.md", readyIntent("itd-4", "The free one", "spc-14", "")) w(sp+"spc-14-free.md", writtenSpec("spc-14", "itd-4")) - b, err = Read(root, nil) + b, err = Read(root, nil, nil) if err != nil { t.Fatal(err) } @@ -324,7 +325,7 @@ func TestTheHeadIsThePicksChoice(t *testing.T) { t.Fatalf("precondition: the pick takes itd-4 over its tie with itd-9: %+v", pick) } - b, err := Read(root, nil) + b, err := Read(root, nil, nil) if err != nil { t.Fatal(err) } @@ -344,7 +345,7 @@ func TestTheHeadIsThePicksChoice(t *testing.T) { // itd-4 in a lane: the pick would not start it again, so the head is the // runner-up. - b, err = Read(root, lanesOf(Started{Intent: "itd-4", Lane: Lane{Run: "run-1", Lane: "lane-1", Step: "implement"}})) + b, err = Read(root, lanesOf(Started{Intent: "itd-4", Lane: Lane{Run: "run-1", Lane: "lane-1", Step: "implement"}}), nil) if err != nil { t.Fatal(err) } @@ -401,7 +402,7 @@ func TestTheHeadPassesOverWhatTheBuildRefusesFromTheRecord(t *testing.T) { w(in+"itd-4-refused.md", refused) w(sp+"spc-14-refused.md", scoredSpec("spc-14", "itd-4")+tc.specAdd) - b, err := Read(root, nil) + b, err := Read(root, nil, nil) if err != nil { t.Fatal(err) } @@ -414,7 +415,7 @@ func TestTheHeadPassesOverWhatTheBuildRefusesFromTheRecord(t *testing.T) { w(in+"itd-9-free.md", readyIntent("itd-9", "The free one", "spc-19", "")) w(sp+"spc-19-free.md", writtenSpec("spc-19", "itd-9")) - if b, err = Read(root, nil); err != nil { + if b, err = Read(root, nil, nil); err != nil { t.Fatal(err) } if got := ids(b.Next); !reflect.DeepEqual(got, []string{"itd-4", "itd-9"}) { @@ -426,3 +427,72 @@ func TestTheHeadPassesOverWhatTheBuildRefusesFromTheRecord(t *testing.T) { }) } } + +// TestTheHeadPassesOverAnIntentAPeerHolds (ruling CC1): the head is judged by +// the peers check the caller hands in, read once for the block and only when a +// head is in reach. An intent it reports held stays in Next and is never the +// head; a fault reading the peers is the block's fault, as it is the pick's; +// and a record with nothing to start pays no peers read at all. +func TestTheHeadPassesOverAnIntentAPeerHolds(t *testing.T) { + root := t.TempDir() + w := func(rel, body string) { + t.Helper() + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + reads := 0 + holding := func(held string) PeerReader { + return func(string) (HeldBy, error) { + reads++ + return func(r intent.ReadyResult) string { + if r.IntentID == held { + return "lane-alpha holds it in shipped/" + } + return "" + }, nil + } + } + + if _, err := Read(root, nil, holding("itd-4")); err != nil || reads != 0 { + t.Fatalf("a record with nothing to start: err %v, %d peers read(s), want none", err, reads) + } + + const in = ".abcd/development/intents/planned/" + const sp = ".abcd/development/specs/open/" + w(in+"itd-4-held.md", readyIntent("itd-4", "The held one", "spc-14", "")) + w(sp+"spc-14-held.md", scoredSpec("spc-14", "itd-4")) + w(in+"itd-9-free.md", readyIntent("itd-9", "The free one", "spc-19", "")) + w(sp+"spc-19-free.md", writtenSpec("spc-19", "itd-9")) + + b, err := Read(root, nil, nil) + if err != nil { + t.Fatal(err) + } + if got := ids(b.Now); !reflect.DeepEqual(got, []string{"itd-4"}) { + t.Fatalf("precondition: without a peers check itd-4 heads, got Now = %v", got) + } + + b, err = Read(root, nil, holding("itd-4")) + if err != nil { + t.Fatal(err) + } + if got := ids(b.Next); !reflect.DeepEqual(got, []string{"itd-4", "itd-9"}) { + t.Errorf("Next = %v, want [itd-4 itd-9]: a held intent is still READY", got) + } + if got := ids(b.Now); !reflect.DeepEqual(got, []string{"itd-9"}) || !b.Now[0].NextUp { + t.Errorf("Now = %+v, want only itd-9 marked next up: a peer holds itd-4", b.Now) + } + if reads != 1 { + t.Errorf("the peers were read %d times for one block, want once", reads) + } + + fault := errors.New("git could not name the common dir") + if _, err := Read(root, nil, func(string) (HeldBy, error) { return nil, fault }); !errors.Is(err, fault) { + t.Errorf("a fault reading the peers: got %v, want it returned", err) + } +} diff --git a/internal/surface/cli/board_status.go b/internal/surface/cli/board_status.go index 05eed3097..3dd7bb2b8 100644 --- a/internal/surface/cli/board_status.go +++ b/internal/surface/cli/board_status.go @@ -27,7 +27,7 @@ func boardStatus(cwd string, stderr io.Writer) *statusblock.Block { if err != nil || !ahoy.Managed(root) { return nil } - b, err := statusblock.Read(root, loop.StatusLanes) + b, err := statusblock.Read(root, loop.StatusLanes, loop.StatusPeers) if err != nil { fmt.Fprintf(stderr, "abcd: the Now / Next / Later block is omitted — %s\n", termsafe.Sanitize(fsutil.RedactHome(err.Error()))) return nil From 1029ff5b32147d5d9dc60675e3e92a1b2f92ea8f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:09:54 +0100 Subject: [PATCH 44/69] feat(capture): run the filing-time match on inbox promote and the consistency pass The press release of itd-2609212137116617 says a new issue is matched against the record when it is filed, but two unattended writers filed without the match. Both now run capture's own match through CaptureRequest.Match, configured by the same layered reader (resolveMatch) and printed by the same renderMatch as the capture verb. - report.Promote takes the match configuration and returns the outcome. It compares the report's own title and prose, not the provenance lines every promoted report carries. Matched on those lines, two unrelated reports linked each other (score 0.767 in the test) and a real double scored only 0.23. - IngestConsistency takes the match configuration. Each filed finding is compared by its summary and explanation. A record the same pass filed is never a candidate, which keeps the pass's rule that two findings of one pass are two findings. - The canonical check is extended, not copied. CaptureRequest gains MatchText (the words to compare in place of Text) and MatchExcept (records not to compare with), and matchAndLink honours both. The reading-ingest route is left alone: it waits on ruling DQ2b. Refs: iss-2609281911024185 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/05-intent.md | 2 +- .../brief/04-surfaces/06-capture.md | 8 + .../development/brief/04-surfaces/30-inbox.md | 8 + commands/inbox.md | 8 +- commands/intent.md | 5 +- internal/core/capture/capture.go | 15 +- internal/core/capture/citationgate_test.go | 6 +- internal/core/capture/consistency.go | 19 ++- internal/core/capture/consistency_test.go | 127 +++++++++++++-- internal/core/capture/match.go | 9 +- internal/core/capture/remedy_required_test.go | 2 +- internal/core/capture/unreadabledir_test.go | 2 +- internal/core/capture/workflow.go | 6 +- internal/core/intent/consistency.go | 11 +- internal/core/report/inbox.go | 24 ++- internal/core/report/inbox_test.go | 24 +-- internal/core/report/promote_match_test.go | 146 ++++++++++++++++++ .../surface/cli/filing_match_surface_test.go | 75 +++++++++ internal/surface/cli/intent_consistency.go | 12 +- internal/surface/cli/report.go | 9 +- 20 files changed, 469 insertions(+), 49 deletions(-) create mode 100644 internal/core/report/promote_match_test.go create mode 100644 internal/surface/cli/filing_match_surface_test.go diff --git a/.abcd/development/brief/04-surfaces/05-intent.md b/.abcd/development/brief/04-surfaces/05-intent.md index 591b56729..349f553bd 100644 --- a/.abcd/development/brief/04-surfaces/05-intent.md +++ b/.abcd/development/brief/04-surfaces/05-intent.md @@ -316,7 +316,7 @@ Later phase — intent-auditor (shape-classification role) scans the corpus | Audit ingest (a verdict JSON path) | Ingests a host-delegated intent-fidelity verdict JSON, validated fail-closed against the schema and the parked review request, and writes its per-criterion verdict and its disposition of each scope condition the intent carries into the shipped intent's `## Audit Notes`, making it the first writer into the scope-condition disposition surface (or quarantines a bad payload, which records every condition `untested`). The machine-readable result says what the ingest recorded — the verdict, a quarantine, or nothing — and carries the acceptance rollup and the disposition split only beside a recorded verdict; a quarantine states the conditions it recorded untested under a name of its own, so its result never reads as a rollup. A second ingest for the same receipt is a no-op when its payload renders to the block on the record, replaces that block in place when it renders differently, and is refused with nothing written when it does not validate. A verdict whose rendered prose cites a record id that names no record is refused, naming the id, with nothing written, wherever the repository's record-lint gates prose citations in the intent store. Each block closes on its own closing line, so prose written below it survives a replacement, and only a marker on a live line of `## Audit Notes` is review state: one in a fenced block or an HTML comment is an example. | (no move; updates `## Audit Notes`) | | Condition disposition (one shipped intent id, optionally one condition id) | **The second writer into the scope-condition disposition surface.** With the intent alone it is read-only: every scope condition the intent carries, with its standing disposition and the block that disposition came from, or `untested (no block)`; the machine-readable form carries the whole history and the fold. With a condition identity it writes one disposition against a **shipped** intent — `survived`, `narrowed`, `falsified` or `untested` — joined to what occasioned it: a reading item at any position, or a delivered intent in `shipped/` whose delivery changed the condition's standing. It appends one dated block to `## Audit Notes`, beside the fidelity verdict's blocks and in the same bullet shape. A condition's standing is its latest reading-occasioned block where it has one, and otherwise its latest verdict block: a verdict overrides a reading-occasioned block only where its rationale names that block's occasion, wherever the two sit in the section; the verdict ingest reports what it leaves standing, and a re-ingest for the same receipt that names the occasion replaces the ingested verdict. Refused, with nothing written: an intent not in `shipped/` (naming its bucket), an identity the intent does not carry or carries twice, a value outside the four, grounds below the substance floor, `narrowed` without a narrowing or a narrowing on any other value, an occasion that does not resolve, and the intent itself as its own occasion. Grounds and narrowing are redacted before the write. When a reading item's `constraint_in_play` cites a different condition's identity, the mismatch is reported and never refused: the reading names the tension and the researcher marks the condition. The block sits under the heading every reading's assembler withholds, so no disposition reaches a reading. | (no move; appends to `## Audit Notes`) | | Consistency (the whole corpus, or one intent id) | **Role 2 — cross-document fidelity** (itd-48). Assembles the corpus — every brief page, and every intent outside `superseded/` reduced to its title, press release, scope, decisions and rule — into one input under the local tier, and writes the request beside it: the five judgement classes (terminology drift, premise contradictions, scope leakage, sequencing impossibilities, naming conflicts), the rubric, the findings shape rendered from the structure the ingest decodes, the host-computed provenance pair the audit's request carries, and the commit the tree stood at. With an intent id the pass is that intent against the rest of the corpus, and every finding must have an end in it; a superseded or unknown intent is refused. The judgement rides the host: the intent-auditor's Role 2 reads the corpus and returns findings, each naming exactly two ends, quoted. The receipt is deterministic over the scope and the corpus, so a re-emit over an unchanged corpus reuses it. | (no move; writes the request and the corpus to the local tier) | -| Consistency ingest (a findings JSON path) | Validates the returned findings fail-closed before anything is written: the request was issued here, the corpus has not moved since (the receipt is recomputed), the provenance pair is the one issued, every class and severity is in its set, each end's path is a corpus document whose text holds the end's quote (twelve characters at least), and no finding repeats another. A finding it would file whose text cites a record id that names no record is refused too, naming the finding and the id, wherever the repository's record-lint gates prose citations in the issue ledger — every finding is checked before the first is filed, so nothing is written. Then it files one capture per finding — an `inconsistency` from an `agent-finding`, found during the pass that names the report, located at its first end, with the report as its evidence — unless an open record already quotes either end and names its document, in which case the finding is linked to that record rather than filed twice; and it writes a dated report on the reviews shelf naming, in its `review_of_commit` pin, the commit the pass read — marked `dirty: true`, with the uncommitted corpus paths named, when the emit or the ingest's own second reading of the tree against that commit finds a corpus document edited, untracked or deleted relative to it — the union of the two, so the mark is never lost to an edited request or a commit made since the emit — since the pass reads the working tree (itd-28's dirty-tree policy: mark, do not block) — then the receipt and every finding with both ends quoted and located and the record it was filed as or linked to. A second run the same day takes the next free suffix; the same findings ingested again are a no-op naming the report. Neither half writes the brief or an intent. | (no move; writes the report and the ledger) | +| Consistency ingest (a findings JSON path) | Validates the returned findings fail-closed before anything is written: the request was issued here, the corpus has not moved since (the receipt is recomputed), the provenance pair is the one issued, every class and severity is in its set, each end's path is a corpus document whose text holds the end's quote (twelve characters at least), and no finding repeats another. A finding it would file whose text cites a record id that names no record is refused too, naming the finding and the id, wherever the repository's record-lint gates prose citations in the issue ledger — every finding is checked before the first is filed, so nothing is written. Then it files one capture per finding — an `inconsistency` from an `agent-finding`, found during the pass that names the report, located at its first end, with the report as its evidence — unless an open record already quotes either end and names its document, in which case the finding is linked to that record rather than filed twice; a finding it files runs capture's filing-time match on its summary and explanation, never against a record the same pass filed, and carries a `duplicates:` or `refines:` link naming each likely double; and it writes a dated report on the reviews shelf naming, in its `review_of_commit` pin, the commit the pass read — marked `dirty: true`, with the uncommitted corpus paths named, when the emit or the ingest's own second reading of the tree against that commit finds a corpus document edited, untracked or deleted relative to it — the union of the two, so the mark is never lost to an edited request or a commit made since the emit — since the pass reads the working tree (itd-28's dirty-tree policy: mark, do not block) — then the receipt and every finding with both ends quoted and located and the record it was filed as or linked to. A second run the same day takes the next free suffix; the same findings ingested again are a no-op naming the report. Neither half writes the brief or an intent. | (no move; writes the report and the ledger) | | `/abcd:intent shape [<itd-N>]` | **Role 3 — kind classification.** Examines whether an intent's declared `kind` (the noun) still fits the corpus. Surfaces *suggested* reclassifications across three live types: `kind_change`, `bundle`, `supersession`. **Bare** scans the corpus; **with `<itd-N>`** checks one intent. Pairs with the reclassify step (action verb that commits a `shape` finding). On-demand only per spc-29 (predecessor store; a later phase); findings land in `.abcd/.work.local/logs/audit/shape-<ts>/report.{json,md}`. Concurrency via `flock(2)` on `.abcd/coordination/shape.lock` (see § 7). Scheduled / continuous invocation is a deferred follow-up. | (stays) | | Reclassify (one intent id, its new kind) | **Late reclassification** (itd-34). A kind change — standalone ↔ bundle-member, joining a bundle another record already names — on a draft or planned record rewrites the kind (and the bundle, set or cleared) in place. A supersession, naming the successor (an intent `itd-M`, or an ADR `adr-M` when a decision redecided the question) and a reason, moves the file to `superseded/` with `superseded_by`, `kind_at_supersession` and the supersession note, and appends the record to the successor's `supersedes` in the same write; superseding one member of a bundle of two leaves the other a bundle-member whose history says the bundle now has one member. Every change appends a `reclassification_history` entry; the reason is one line, redacted. Refused with nothing written: a shipped intent's kind change (the remedy for a rule found after the fact is a discipline that supersedes it), any move into disciplines/, a planned member leaving its bundle's shared spec, a missing or superseded successor, and a held record. The result names every path moved and written. | `→ superseded/` for a supersession; otherwise no move | | Hold (one intent id and a reason) | Holds a draft or planned intent: writes `held: "<reason>"` — the reason is required, single-line and redacted through the store's scanner before the write, and the JSON reports `redacted` like the other write verbs. Planning and closing a spec refuse a held record before anything moves, naming the reason and the unhold that lifts it; `abcd <itd-N>` reports the hold as the next move. Refused on a record already held (naming the standing reason — an updated reason is an unhold then a hold) and on a shipped, superseded or discipline record. The `record_provenance` lint rule reports a `held` value in a shape the verb never writes; a legal hand-typed line is byte-identical to the write and is not reported. | (no move; writes `held`) | diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index 1a771de60..1eb3daa5e 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -118,6 +118,14 @@ it and removes it by deleting its line, which leaves an ordinary record. The match proposes no `reverses` and no `supersedes`: the itd-84 discipline keeps a reversal advisory and human. +The same match runs on the ledger's two unattended writers, through the same +core and configuration: a promoted inbox report is compared by its own title +and prose, and each finding the consistency pass files by its summary and +explanation. Neither compares the lines every record it files carries (the +inbox's provenance, the pass's evidence line), on which two unrelated records +would match, and the consistency pass never compares a record the same pass +filed. + 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 default. diff --git a/.abcd/development/brief/04-surfaces/30-inbox.md b/.abcd/development/brief/04-surfaces/30-inbox.md index 32468c0e6..58f2816f6 100644 --- a/.abcd/development/brief/04-surfaces/30-inbox.md +++ b/.abcd/development/brief/04-surfaces/30-inbox.md @@ -68,6 +68,14 @@ test holds the constant to the checkout the tests run in. A shallow clone, whose first commit is not the root, is refused rather than guessed at; a fork shares the root commit and promotes. +The capture runs capture's filing-time match (itd-2609212137116617) on the +report's own title and prose, and writes a `duplicates:` or `refines:` link +naming each likely double, exactly as a capture filed by hand does. The +provenance lines every promoted report carries (the sender's key, the inbox, +the kind, the version, the surface, the evidence line) are not compared: two +unrelated reports would otherwise match on them alone. The match never refuses +the promotion, and the output lists what it found. + The capture carries: - the report's severity and category, and `source: managed-repo`; diff --git a/commands/inbox.md b/commands/inbox.md index c214a4883..ec9ed652c 100644 --- a/commands/inbox.md +++ b/commands/inbox.md @@ -64,8 +64,12 @@ It files a capture through the capture verb's own path and redactor, with repository" in place of the sender's name, and the report id as its evidence. A record id the report names is the sender's, so it is written as one word (`iss12`) and cites nothing in abcd's record. -Tell the user the `capture` id and its `path`, and relay `redacted` or -`redaction_degraded` when present. The report is kept, marked promoted. A +The capture runs the capture verb's filing-time match on the report's own +title and prose, never on the provenance lines every promoted report carries, +and writes a `duplicates:` or `refines:` link naming each likely double. +Tell the user the `capture` id and its `path`, relay `redacted` or +`redaction_degraded` when present, and relay `match`: each link written, for a +person to confirm by leaving it or remove by deleting its line. The report is kept, marked promoted. A refusal exits 2 and writes nothing: a promotion outside a checkout of abcd, an unreadable report, one already promoted (the refusal names its capture), an id with no report, a capture the ledger refuses (the report still waits), or a diff --git a/commands/intent.md b/commands/intent.md index 3de91dc6c..0538cf215 100644 --- a/commands/intent.md +++ b/commands/intent.md @@ -1020,7 +1020,10 @@ A payload that validates is written in two places. Each finding is filed as one issue (`inconsistency`, from an `agent-finding`, located at its first end, with the report as its evidence) — unless an open record already quotes either end and names its document, in which case it is linked to that record and nothing -is filed. And one dated report lands on the reviews shelf, +is filed. A finding it files runs the capture verb's filing-time match on its +summary and explanation, never against a record the same pass filed, and +carries a `duplicates:` or `refines:` link naming each likely double; each row +reports it as `match`. And one dated report lands on the reviews shelf, `.abcd/work/reviews/<date>-consistency[-<itd-N>]/00-summary.md`, pinned to the commit the pass read, listing every finding with both ends quoted and located and the record it was filed as or linked to; a second run the same day takes diff --git a/internal/core/capture/capture.go b/internal/core/capture/capture.go index 9b5c17ca2..cc473f807 100644 --- a/internal/core/capture/capture.go +++ b/internal/core/capture/capture.go @@ -186,9 +186,20 @@ type CaptureRequest struct { // lock, and writes a `duplicates:` or `refines:` link naming each likely // double (itd-2609212137116617). It never refuses the capture: a match that // cannot run says why on the result and the record is filed without it. - // nil files the record unmatched, as a caller with its own matching (the - // inbox drain, the consistency pass) does. + // nil files the record unmatched. Match *match.Config + // MatchText, when non-empty, is the text the match compares in place of + // Text: the finding's own words, for a filer whose record also carries + // lines every record it files shares (the inbox's provenance, the + // consistency pass's evidence line). Matched on those, two unrelated + // records would link each other on the boilerplate alone. Empty compares + // Text. + MatchText string + // MatchExcept names records the match does not compare with: a filer + // that files several records in one pass passes the ones it has already + // filed, so two findings of one pass are never linked as doubles of each + // other. + MatchExcept []string } // CaptureResult is the outcome of a successful Capture. The timestamp-numeric diff --git a/internal/core/capture/citationgate_test.go b/internal/core/capture/citationgate_test.go index 6b6be44dc..dca20216d 100644 --- a/internal/core/capture/citationgate_test.go +++ b/internal/core/capture/citationgate_test.go @@ -165,7 +165,7 @@ func TestConsistencyIngestRefusesAnUnresolvedCitation(t *testing.T) { t.Run("armed: refused, nothing written", func(t *testing.T) { root := consistencyArmedRepo(t) - _, err := IngestConsistency(root, cite(consistencyPayload(t, root)), "2026-09-26") + _, err := IngestConsistency(root, cite(consistencyPayload(t, root)), "2026-09-26", nil) if !errors.Is(err, ErrUnresolvedCitation) || !strings.Contains(err.Error(), dangling) || !strings.Contains(err.Error(), "consistency finding 1") { t.Fatalf("err = %v, want a refusal naming finding 1 and %s", err, dangling) @@ -181,7 +181,7 @@ func TestConsistencyIngestRefusesAnUnresolvedCitation(t *testing.T) { }) t.Run("armed: a clean finding is filed", func(t *testing.T) { root := consistencyArmedRepo(t) - res, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26") + res, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26", nil) if err != nil || len(res.Filed) != 1 { t.Fatalf("ingest = %+v %v, want the finding filed", res, err) } @@ -189,7 +189,7 @@ func TestConsistencyIngestRefusesAnUnresolvedCitation(t *testing.T) { t.Run("no gate registered: refused", func(t *testing.T) { withNoProseGate(t) root := consistencyLedgerRepo(t) - _, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26") + _, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26", nil) if err == nil || !strings.Contains(err.Error(), "no prose-citation gate is registered") { t.Fatalf("err = %v, want the unregistered gate refused", err) } diff --git a/internal/core/capture/consistency.go b/internal/core/capture/consistency.go index 503d781b3..f4ca858a8 100644 --- a/internal/core/capture/consistency.go +++ b/internal/core/capture/consistency.go @@ -2,11 +2,13 @@ package capture import ( "fmt" + "slices" "strings" "unicode" "github.com/intentdriven/abcd/internal/core/intent" "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/record/match" ) // consistency.go is the ledger half of the intent consistency pass (itd-48, @@ -20,8 +22,12 @@ import ( // IngestConsistency ingests a consistency findings payload with the ledger as // its filer. date is the report's date (YYYY-MM-DD; empty is today in UTC). -func IngestConsistency(repoRoot string, payload []byte, date string) (intent.ConsistencyIngestResult, error) { +// mc, when non-nil, runs capture's filing-time match on every record the pass +// files, so a finding that doubles a record in other words is filed with a +// typed link naming it; nil files unmatched. +func IngestConsistency(repoRoot string, payload []byte, date string, mc *match.Config) (intent.ConsistencyIngestResult, error) { var open []Issue + var filedHere []string loaded := false // The open records are read once, on the first finding, and only records // that were open BEFORE this pass count: two findings of one pass that @@ -51,6 +57,14 @@ func IngestConsistency(repoRoot string, payload []byte, date string) (intent.Con // H12): the record is filed, and a drain skips it until a person // writes a real remedy. Remedy: issueschema.MachineRemedy, + // The filing-time match (itd-2609212137116617) compares the + // finding's own words: the ends' paths, the class line and the + // evidence line are shared by every finding of a pass. The + // records this pass has filed are not compared, for the reason + // loadOpen gives. + Match: mc, + MatchText: f.Summary + "\n\n" + f.Explanation, + MatchExcept: slices.Clone(filedHere), } } // A finding an open record already holds is linked, and writes nothing, so @@ -82,7 +96,8 @@ func IngestConsistency(repoRoot string, payload []byte, date string) (intent.Con if err != nil { return intent.ConsistencyFiling{}, err } - return intent.ConsistencyFiling{IssueID: res.ID}, nil + filedHere = append(filedHere, res.ID) + return intent.ConsistencyFiling{IssueID: res.ID, Match: res.Match}, nil } return intent.IngestConsistency(intent.ConsistencyIngestRequest{ RepoRoot: repoRoot, Payload: payload, Date: date, File: filer, Check: check, diff --git a/internal/core/capture/consistency_test.go b/internal/core/capture/consistency_test.go index c313f479a..355c90384 100644 --- a/internal/core/capture/consistency_test.go +++ b/internal/core/capture/consistency_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/core/record/match" "github.com/intentdriven/abcd/internal/gittest" ) @@ -35,6 +36,20 @@ func consistencyLedgerRepo(t *testing.T) string { // consistencyPayload emits a corpus request and returns a findings payload // echoing its provenance, carrying the one contradiction between itd-10 and itd-11. func consistencyPayload(t *testing.T, root string) []byte { + t.Helper() + return consistencyPayloadWith(t, root, []any{map[string]any{ + "class": "premise_contradiction", "severity": "major", + "summary": "itd-10 and itd-11 disagree on how many specs an intent owns", + "explanation": "One says exactly one spec for life; the other says one or more.", + "ends": []any{ + map[string]any{"path": cxA, "quote": cxQuoteA}, + map[string]any{"path": cxB, "quote": cxQuoteB}, + }, + }}) +} + +// consistencyPayloadWith is consistencyPayload carrying the findings given. +func consistencyPayloadWith(t *testing.T, root string, findings []any) []byte { t.Helper() em, err := intent.EmitConsistency(root, "", intent.ConsistencyEmitOptions{}) if err != nil { @@ -51,15 +66,7 @@ func consistencyPayload(t *testing.T, root string) []byte { "receipt_id": em.ReceiptID, "verifier": map[string]any{"id": "intent-auditor", "version": "claude-opus-5-5"}, "policy": map[string]any{"rubric_hash": rubric, "prompt_hash": prompt}, - "findings": []any{map[string]any{ - "class": "premise_contradiction", "severity": "major", - "summary": "itd-10 and itd-11 disagree on how many specs an intent owns", - "explanation": "One says exactly one spec for life; the other says one or more.", - "ends": []any{ - map[string]any{"path": cxA, "quote": cxQuoteA}, - map[string]any{"path": cxB, "quote": cxQuoteB}, - }, - }}, + "findings": findings, }) if err != nil { t.Fatal(err) @@ -73,7 +80,7 @@ func consistencyPayload(t *testing.T, root string) []byte { // evidence, and related to the intents it sits in. func TestConsistencyFilesOneCapturePerFinding(t *testing.T) { root := consistencyLedgerRepo(t) - res, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26") + res, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26", nil) if err != nil { t.Fatal(err) } @@ -133,7 +140,7 @@ func TestConsistencyLinksAFindingAnOpenRecordHolds(t *testing.T) { t.Fatal(err) } } - res, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26") + res, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26", nil) if err != nil { t.Fatal(err) } @@ -144,3 +151,101 @@ func TestConsistencyLinksAFindingAnOpenRecordHolds(t *testing.T) { }) } } + +// The pass's words for a finding a person already filed in other words: the +// held record doubles it without quoting either end, so the pass's own +// end-matching (openRecordHolding) does not hold it, and only the filing-time +// match can. +const ( + cxDoubleSummary = "The planned intent and the shipped intent disagree on how many specs an intent owns over its lifetime" + cxDoubleExplain = "The planned record binds every intent to exactly one spec for its whole lifetime, " + + "while the shipped decision lets an intent own several specs closed in sequence." + cxHeld = "Planned and shipped intents disagree on how many specs an intent owns over its lifetime: " + + "the planned record binds each intent to exactly one spec for its whole lifetime, and the " + + "shipped decision lets an intent own several specs closed in sequence." +) + +func cxFinding(summary, explanation string) map[string]any { + return map[string]any{ + "class": "premise_contradiction", "severity": "major", + "summary": summary, "explanation": explanation, + "ends": []any{ + map[string]any{"path": cxA, "quote": cxQuoteA}, + map[string]any{"path": cxB, "quote": cxQuoteB}, + }, + } +} + +// A finding the pass files that doubles an open record is written with +// capture's typed link naming it, and the row carries the match. +func TestConsistencyLinksANearDuplicateAtFiling(t *testing.T) { + root := consistencyLedgerRepo(t) + held := captureText(t, root, "", cxHeld, nil) + captureText(t, root, "", matchFiller1, nil) + captureText(t, root, "", matchFiller2, nil) + + res, err := IngestConsistency(root, consistencyPayloadWith(t, root, []any{cxFinding(cxDoubleSummary, cxDoubleExplain)}), "2026-09-26", bundled()) + if err != nil { + t.Fatal(err) + } + if len(res.Filed) != 1 || len(res.Rows) != 1 { + t.Fatalf("ingest = %+v; want one record filed", res) + } + o := res.Rows[0].Match + if o == nil || len(o.Matches) == 0 { + t.Fatalf("no match on the row: %+v", o) + } + m := o.Matches[0] + if m.ID != held.ID || !m.Linked || (m.Relation != match.Duplicates && m.Relation != match.Refines) { + t.Fatalf("match = %+v, want %s linked", m, held.ID) + } + list, err := List(ListRequest{RepoRoot: root, State: StateOpen}) + if err != nil { + t.Fatal(err) + } + for _, iss := range list.Issues { + if iss.ID != res.Filed[0] { + continue + } + got := iss.Duplicates + if m.Relation == match.Refines { + got = iss.Refines + } + if len(got) != 1 || got[0] != held.ID { + t.Fatalf("filed record's %s = %v, want [%s]", m.Relation, got, held.ID) + } + return + } + t.Fatalf("filed record %s not listed", res.Filed[0]) +} + +// The match reads the finding's own words, not the lines every finding of a +// pass is filed with, and never the records this pass has already filed: two +// findings of one pass are two findings, as the pass's own end-matching has +// it. +func TestConsistencyNeverLinksTwoFindingsOfOnePass(t *testing.T) { + root := consistencyLedgerRepo(t) + captureText(t, root, "", matchFiller1, nil) + captureText(t, root, "", matchFiller2, nil) + // One finding under two classes: the pass files them as two records. + second := cxFinding(cxDoubleSummary, cxDoubleExplain) + second["class"] = "scope_leakage" + payload := consistencyPayloadWith(t, root, []any{cxFinding(cxDoubleSummary, cxDoubleExplain), second}) + res, err := IngestConsistency(root, payload, "2026-09-26", bundled()) + if err != nil { + t.Fatal(err) + } + if len(res.Filed) != 2 { + t.Fatalf("ingest = %+v; want two records filed", res) + } + for _, r := range res.Rows { + if r.Match == nil { + t.Fatalf("row %d was not matched at all", r.Number) + } + for _, m := range r.Match.Matches { + if m.ID == res.Filed[0] || m.ID == res.Filed[1] { + t.Fatalf("finding %d was linked to a record of its own pass: %+v", r.Number, m) + } + } + } +} diff --git a/internal/core/capture/match.go b/internal/core/capture/match.go index 5341aeb92..a7cb9090c 100644 --- a/internal/core/capture/match.go +++ b/internal/core/capture/match.go @@ -2,6 +2,7 @@ package capture import ( "fmt" + "slices" "strings" "github.com/intentdriven/abcd/internal/core/intent" @@ -74,8 +75,9 @@ func matchCandidates(repoRoot, issuesRoot string, cfg match.Config) ([]match.Can // record's rendered content, validating the frontmatter they join. It runs // under the ledger lock, before the write. It never fails the capture: a // candidate set that cannot be read, or a link the schema would refuse, comes -// back as an outcome saying why, with the content unlinked. -func matchAndLink(repoRoot, issuesRoot string, cfg match.Config, text, content string, fm map[string]any) (string, *match.Outcome) { +// back as an outcome saying why, with the content unlinked. A candidate named +// in except is not compared. +func matchAndLink(repoRoot, issuesRoot string, cfg match.Config, text string, except []string, content string, fm map[string]any) (string, *match.Outcome) { if match.Short(text) { o := match.Rank(text, nil, cfg.Threshold) return content, &o @@ -85,6 +87,9 @@ func matchAndLink(repoRoot, issuesRoot string, cfg match.Config, text, content s o := match.Unread(cfg.Threshold, err) return content, &o } + if len(except) > 0 { + cands = slices.DeleteFunc(cands, func(c match.Candidate) bool { return slices.Contains(except, c.ID) }) + } o := match.Rank(text, cands, cfg.Threshold) links := o.Links() if len(links) == 0 { diff --git a/internal/core/capture/remedy_required_test.go b/internal/core/capture/remedy_required_test.go index dfa60188d..ef57cdc37 100644 --- a/internal/core/capture/remedy_required_test.go +++ b/internal/core/capture/remedy_required_test.go @@ -74,7 +74,7 @@ func TestCaptureAcceptsTheMachineRemedy(t *testing.T) { // filer, so its record carries the machine value. func TestConsistencyFilesTheMachineRemedy(t *testing.T) { root := consistencyLedgerRepo(t) - if _, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26"); err != nil { + if _, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26", nil); err != nil { t.Fatal(err) } list, err := List(ListRequest{RepoRoot: root, State: StateOpen}) diff --git a/internal/core/capture/unreadabledir_test.go b/internal/core/capture/unreadabledir_test.go index 89b475a85..574e24817 100644 --- a/internal/core/capture/unreadabledir_test.go +++ b/internal/core/capture/unreadabledir_test.go @@ -52,7 +52,7 @@ func TestMatchReportsAnUnreadableStatusDirectoryAsUnread(t *testing.T) { t.Fatalf("the intent create's candidate set read an unreadable resolved/ as empty: %v", err) } const content = "---\nid: planted\n---\n" - got, o := matchAndLink(repo, ir, *bundled(), plantedDouble, content, map[string]any{}) + got, o := matchAndLink(repo, ir, *bundled(), plantedDouble, nil, content, map[string]any{}) if o == nil || !strings.Contains(o.Skipped, "could not be read") { t.Fatalf("an unreadable resolved/ did not read as an unread record set: %+v", o) } diff --git a/internal/core/capture/workflow.go b/internal/core/capture/workflow.go index 62eec25ce..302c499a3 100644 --- a/internal/core/capture/workflow.go +++ b/internal/core/capture/workflow.go @@ -290,7 +290,11 @@ func commitCapture(repoRoot, issuesRoot string, req CaptureRequest, issID, slug, // adds links to the content and never fails the write. var matched *match.Outcome if req.Match != nil { - content, matched = matchAndLink(repoRoot, issuesRoot, *req.Match, req.Text, content, fm) + text := req.Text + if req.MatchText != "" { + text = req.MatchText + } + content, matched = matchAndLink(repoRoot, issuesRoot, *req.Match, text, req.MatchExcept, content, fm) } if werr := writeLedgerFile(repoRoot, issuesRoot, placeholder, []byte(content)); werr != nil { return werr diff --git a/internal/core/intent/consistency.go b/internal/core/intent/consistency.go index b3e4cdb8a..e8ba0b3c4 100644 --- a/internal/core/intent/consistency.go +++ b/internal/core/intent/consistency.go @@ -19,6 +19,7 @@ import ( "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/mdrecord" + "github.com/intentdriven/abcd/internal/core/record/match" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" @@ -621,6 +622,9 @@ type consistencyReview struct { type ConsistencyFiling struct { IssueID string `json:"issue_id"` Linked bool `json:"linked"` + // Match is the filing-time match's outcome (itd-2609212137116617) on a + // record the ledger filed, when the ingest asked for one. + Match *match.Outcome `json:"match,omitempty"` } // ConsistencyFiler files one finding in the ledger, or names the open record @@ -650,8 +654,9 @@ type ConsistencyIngestRequest struct { // ConsistencyRow is one finding as the report and the result carry it. type ConsistencyRow struct { ConsistencyFinding - IssueID string `json:"issue_id"` - Linked bool `json:"linked"` + IssueID string `json:"issue_id"` + Linked bool `json:"linked"` + Match *match.Outcome `json:"match,omitempty"` } // ConsistencyIngestResult reports one ingest. @@ -736,7 +741,7 @@ func IngestConsistency(req ConsistencyIngestRequest) (ConsistencyIngestResult, e "no report was written, and ingesting the same findings again links those records rather than filing them twice", f.Number, len(rv.Findings), err, orNone(res.Filed)) } - res.Rows = append(res.Rows, ConsistencyRow{ConsistencyFinding: f, IssueID: filing.IssueID, Linked: filing.Linked}) + res.Rows = append(res.Rows, ConsistencyRow{ConsistencyFinding: f, IssueID: filing.IssueID, Linked: filing.Linked, Match: filing.Match}) if filing.Linked { res.Linked = append(res.Linked, filing.IssueID) } else { diff --git a/internal/core/report/inbox.go b/internal/core/report/inbox.go index 93bcfd626..28403053c 100644 --- a/internal/core/report/inbox.go +++ b/internal/core/report/inbox.go @@ -15,6 +15,7 @@ import ( "github.com/intentdriven/abcd/internal/core/capture" "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/record/match" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" @@ -547,6 +548,10 @@ type Promoted struct { // Resumed says this call completed an earlier promotion that filed its // capture but did not finish moving the report: nothing new was filed. Resumed bool `json:"resumed,omitempty"` + // Match is the filing-time match's outcome (itd-2609212137116617) when + // the promotion asked for one: the links written onto the capture, the + // near misses, or why nothing was compared. + Match *match.Outcome `json:"match,omitempty"` } // Source is the capture source a promoted report is filed under. @@ -564,7 +569,12 @@ const Source = "managed-repo" // a move that fails leaves a report still waiting whose capture is already on // record. Promoting it again files nothing: it finishes the move and names the // capture the first attempt filed (Resumed). -func Promote(ledgerRoot, id string) (Promoted, error) { +// +// mc, when non-nil, runs capture's filing-time match (itd-2609212137116617) +// on the report's own title and prose, and the capture carries a typed link +// naming each likely double, exactly as a capture filed by hand does. nil +// files the capture unmatched. +func Promote(ledgerRoot, id string, mc *match.Config) (Promoted, error) { m := idRe.FindStringSubmatch(id) if m == nil { return Promoted{}, fmt.Errorf("%w: %q is not a report id (rpt- and sixteen digits)", ErrRefused, id) @@ -626,6 +636,7 @@ func Promote(ledgerRoot, id string) (Promoted, error) { return move(done.Capture) } req := captureRequest(ledgerRoot, id, *e.Report) + req.Match = mc res, err := capture.Capture(req) if err != nil { // Capture writes transactionally and sweeps its reservation on any @@ -633,7 +644,7 @@ func Promote(ledgerRoot, id string) (Promoted, error) { // promotion is refused (exit 2) whatever the ledger's reason. return fmt.Errorf("%w: the capture was refused, and the report still waits: %w", ErrRefused, err) } - out = Promoted{Report: id, Capture: res.ID, Path: res.Path, Redacted: res.Redacted, Degraded: res.Degraded} + out = Promoted{Report: id, Capture: res.ID, Path: res.Path, Redacted: res.Redacted, Degraded: res.Degraded, Match: res.Match} line, err := json.Marshal(promotion{Report: id, Capture: res.ID, Path: res.Path, At: now().UTC().Format(time.RFC3339)}) if err != nil { return err @@ -663,10 +674,12 @@ func captureRequest(ledgerRoot, id string, r Report) capture.CaptureRequest { rewrote = rewrote || n > 0 return out } + // The report's own words, which the filing-time match compares: the + // provenance lines below are the same on every promoted report, and + // matched on them two unrelated reports would link each other. + own := scrub(r.Title) + "\n\n" + scrub(r.Prose) var b strings.Builder - b.WriteString(scrub(r.Title)) - b.WriteString("\n\n") - b.WriteString(scrub(r.Prose)) + b.WriteString(own) b.WriteString("\n") if r.Remedy != "" { fmt.Fprintf(&b, "\nRemedy the reporter proposes: %s\n", scrub(r.Remedy)) @@ -698,6 +711,7 @@ func captureRequest(ledgerRoot, id string, r Report) capture.CaptureRequest { FoundDuring: fmt.Sprintf("abcd inbox report %s from %s (root commit %s)", id, GenericSender, r.SenderKey), FoundAt: foundAt, Remedy: issueschema.MachineRemedy, + MatchText: own, } } diff --git a/internal/core/report/inbox_test.go b/internal/core/report/inbox_test.go index e49834c0c..0c672d455 100644 --- a/internal/core/report/inbox_test.go +++ b/internal/core/report/inbox_test.go @@ -185,7 +185,7 @@ func TestUnknownVersionIsListedUnreadable(t *testing.T) { if tally, _ := Count(); tally.Reports != 1 { t.Errorf("Count = %+v, want the unreadable report counted", tally) } - if _, err := Promote(abcdCheckout(t).Root(), list[0].ID); !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), "unreadable") { + if _, err := Promote(abcdCheckout(t).Root(), list[0].ID, nil); !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), "unreadable") { t.Errorf("Promote(unreadable) = %v, want a refusal", err) } } @@ -223,7 +223,7 @@ func TestPromoteFingerprintsAndNeverNamesTheSender(t *testing.T) { t.Fatalf("a report filed itself before anyone acted:\n%s", st) } - p, err := Promote(ledger.Root(), f.ID) + p, err := Promote(ledger.Root(), f.ID, nil) if err != nil { t.Fatalf("Promote: %v", err) } @@ -270,7 +270,7 @@ func TestPromoteFingerprintsAndNeverNamesTheSender(t *testing.T) { if e.SenderName != name { t.Errorf("the inbox stopped naming the sender: %+v", e) } - if _, err := Promote(ledger.Root(), f.ID); !errors.Is(err, ErrRefused) { + if _, err := Promote(ledger.Root(), f.ID, nil); !errors.Is(err, ErrRefused) { t.Errorf("a second promote = %v, want a refusal", err) } } @@ -351,7 +351,7 @@ func TestPromoteRetryAfterAFailedMoveFilesOneCapture(t *testing.T) { if err := os.MkdirAll(filepath.Join(blocker, "x"), 0o700); err != nil { t.Fatal(err) } - if _, err := Promote(ledger.Root(), f.ID); err == nil { + if _, err := Promote(ledger.Root(), f.ID, nil); err == nil { t.Fatal("Promote succeeded with its destination occupied") } if err := os.RemoveAll(blocker); err != nil { @@ -366,7 +366,7 @@ func TestPromoteRetryAfterAFailedMoveFilesOneCapture(t *testing.T) { t.Fatalf("the failed promotion filed %d captures, want 1", len(first)) } - p, err := Promote(ledger.Root(), f.ID) + p, err := Promote(ledger.Root(), f.ID, nil) if err != nil { t.Fatalf("retry: %v", err) } @@ -379,7 +379,7 @@ func TestPromoteRetryAfterAFailedMoveFilesOneCapture(t *testing.T) { if e, err := Show(f.ID); err != nil || e.State != StatePromoted || e.PromotedTo != p.Capture { t.Errorf("Show after retry = %+v, %v", e, err) } - if _, err := Promote(ledger.Root(), f.ID); !errors.Is(err, ErrRefused) { + if _, err := Promote(ledger.Root(), f.ID, nil); !errors.Is(err, ErrRefused) { t.Errorf("a third promote = %v, want a refusal", err) } } @@ -409,7 +409,7 @@ func TestPromoteRefusesOutsideAbcdsOwnCheckout(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = Promote(other.Root(), f.ID) + _, err = Promote(other.Root(), f.ID, nil) if !errors.Is(err, ErrRefused) { t.Fatalf("Promote(unrelated repository) = %v, want a refusal", err) } @@ -429,7 +429,7 @@ func TestPromoteRefusesOutsideAbcdsOwnCheckout(t *testing.T) { } abcd := abcdCheckout(t) - if p, err := Promote(abcd.Root(), f.ID); err != nil || !strings.HasPrefix(p.Capture, "iss-") { + if p, err := Promote(abcd.Root(), f.ID, nil); err != nil || !strings.HasPrefix(p.Capture, "iss-") { t.Fatalf("Promote(abcd's checkout) = %+v, %v", p, err) } } @@ -457,7 +457,7 @@ func TestPromotedCaptureCitesNothingOfTheSenders(t *testing.T) { if err != nil { t.Fatal(err) } - p, err := Promote(ledger.Root(), f.ID) + p, err := Promote(ledger.Root(), f.ID, nil) if err != nil { t.Fatalf("Promote: %v", err) } @@ -502,7 +502,7 @@ func TestACaptureRefusalIsARefusal(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := Promote(ledger.Root(), f.ID); !errors.Is(err, ErrRefused) { + if _, err := Promote(ledger.Root(), f.ID, nil); !errors.Is(err, ErrRefused) { t.Fatalf("Promote(symlinked ledger) = %v, want a refusal", err) } if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 { @@ -579,7 +579,7 @@ func TestAnInboxPathThatIsNotARealDirectoryIsARefusal(t *testing.T) { if _, err := Show(id); !errors.Is(err, ErrRefused) { t.Errorf("Show = %v, want a refusal", err) } - if _, err := Promote(ledger.Root(), id); !errors.Is(err, ErrRefused) { + if _, err := Promote(ledger.Root(), id, nil); !errors.Is(err, ErrRefused) { t.Errorf("Promote = %v, want a refusal", err) } if _, err := File(mustParse(t, filled(t)), Sender{Key: strings.Repeat("9", 40), Name: "linked"}); !errors.Is(err, ErrRefused) { @@ -801,7 +801,7 @@ func TestAPromotedReportIsIneligibleForADrain(t *testing.T) { if err != nil { t.Fatalf("File: %v", err) } - p, err := Promote(ledger.Root(), f.ID) + p, err := Promote(ledger.Root(), f.ID, nil) if err != nil { t.Fatalf("Promote: %v", err) } diff --git a/internal/core/report/promote_match_test.go b/internal/core/report/promote_match_test.go new file mode 100644 index 000000000..a972011f0 --- /dev/null +++ b/internal/core/report/promote_match_test.go @@ -0,0 +1,146 @@ +package report + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/record/match" +) + +// promote_match_test.go covers the filing-time match on the promoted inbox +// report (itd-2609212137116617, iss-2609281911024185): a promoted report is +// matched against the ledger exactly as a capture is, on the report's own +// title and prose, never on the provenance lines every promoted report +// carries. + +const ( + pmFinding = "The capture ledger reader silently skips a record whose frontmatter carries " + + "a duplicated key, so the finding disappears from every listing without a warning." + pmDoubleTitle = "Ledger reader skips records with a duplicated frontmatter key" + pmDouble = "Capture ledger reader silently skips any record whose frontmatter carries a " + + "duplicated key: the finding disappears from every listing, and no warning is printed." + pmFiller1 = "The site builder renders a stale anchor for a heading renamed since the last build." + pmFiller2 = "The history store drops a transcript that exceeds its byte budget without saying so." +) + +// reportWith is the filled template with its title and prose replaced. +func reportWith(t *testing.T, title, prose string) string { + t.Helper() + s := filled(t) + s = strings.Replace(s, `title: "capture refuses a slug with a digit first"`, `title: "`+title+`"`, 1) + s = strings.Replace(s, "Running capture with a slug of 9lives was refused.", prose, 1) + return s +} + +func plantOpen(t *testing.T, root, text string) string { + t.Helper() + res, err := capture.Capture(capture.CaptureRequest{ + RepoRoot: root, Text: text, Severity: capture.SeverityMinor, Category: "bug", + Source: "user-observation", FoundDuring: "fixture", Remedy: issueschema.MachineRemedy, + }) + if err != nil { + t.Fatalf("plant: %v", err) + } + return res.ID +} + +func bundledMatch() *match.Config { c := match.Bundled(); return &c } + +// A promoted report that doubles an open record is written with capture's +// typed link naming it, and the promotion carries the match. +func TestPromoteLinksANearDuplicateOfAnOpenRecord(t *testing.T) { + sandbox(t, time.Date(2026, 9, 30, 9, 0, 0, 0, time.UTC)) + ledger := abcdCheckout(t) + held := plantOpen(t, ledger.Root(), pmFinding) + plantOpen(t, ledger.Root(), pmFiller1) + plantOpen(t, ledger.Root(), pmFiller2) + + f, err := File(mustParse(t, reportWith(t, pmDoubleTitle, pmDouble)), Sender{Key: strings.Repeat("d", 40), Name: "delta"}) + if err != nil { + t.Fatalf("File: %v", err) + } + p, err := Promote(ledger.Root(), f.ID, bundledMatch()) + if err != nil { + t.Fatalf("Promote: %v", err) + } + if p.Match == nil || len(p.Match.Matches) == 0 { + t.Fatalf("no match reported: %+v", p.Match) + } + m := p.Match.Matches[0] + if m.ID != held || !m.Linked || (m.Relation != match.Duplicates && m.Relation != match.Refines) { + t.Fatalf("match = %+v, want %s linked", m, held) + } + body, err := os.ReadFile(filepath.Join(ledger.Root(), filepath.FromSlash(p.Path))) + if err != nil { + t.Fatal(err) + } + if want := "\n" + string(m.Relation) + ": [" + held + "]\n"; !strings.Contains(string(body), want) { + t.Fatalf("the promoted capture carries no %q link:\n%s", strings.TrimSpace(want), body) + } +} + +// Two unrelated reports share only the provenance every promoted report +// carries (the sender's key, the inbox, the kind, the version, the surface, +// the evidence line). The second must not be matched to the first: the match +// reads the report's own title and prose. +func TestPromoteDoesNotMatchOnTheInboxBoilerplate(t *testing.T) { + sandbox(t, time.Date(2026, 9, 30, 10, 0, 0, 0, time.UTC)) + ledger := abcdCheckout(t) + sender := Sender{Key: strings.Repeat("e", 40), Name: "echo"} + + a, err := File(mustParse(t, reportWith(t, "Site anchors go stale", "Renamed headings keep their old anchor.")), sender) + if err != nil { + t.Fatalf("File a: %v", err) + } + pa, err := Promote(ledger.Root(), a.ID, bundledMatch()) + if err != nil { + t.Fatalf("Promote a: %v", err) + } + setClock(t, time.Date(2026, 9, 30, 10, 1, 0, 0, time.UTC)) + b, err := File(mustParse(t, reportWith(t, "Transcripts over budget vanish", "History drops oversized transcripts quietly.")), sender) + if err != nil { + t.Fatalf("File b: %v", err) + } + pb, err := Promote(ledger.Root(), b.ID, bundledMatch()) + if err != nil { + t.Fatalf("Promote b: %v", err) + } + if pb.Match == nil { + t.Fatal("no match outcome: the promotion was not matched at all") + } + for _, m := range pb.Match.Matches { + if m.ID == pa.Capture { + t.Fatalf("%s matched %s on the inbox boilerplate alone: %+v", pb.Capture, pa.Capture, m) + } + } + body, err := os.ReadFile(filepath.Join(ledger.Root(), filepath.FromSlash(pb.Path))) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), pa.Capture) { + t.Fatalf("the second capture names the first:\n%s", body) + } +} + +// A promotion without a match configuration files unmatched, as before. +func TestPromoteWithoutAMatchReportsNone(t *testing.T) { + sandbox(t, time.Date(2026, 9, 30, 11, 0, 0, 0, time.UTC)) + ledger := abcdCheckout(t) + plantOpen(t, ledger.Root(), pmFinding) + f, err := File(mustParse(t, reportWith(t, pmDoubleTitle, pmDouble)), Sender{Key: strings.Repeat("f", 40), Name: "foxtrot"}) + if err != nil { + t.Fatalf("File: %v", err) + } + p, err := Promote(ledger.Root(), f.ID, nil) + if err != nil { + t.Fatalf("Promote: %v", err) + } + if p.Match != nil { + t.Fatalf("match = %+v, want none", p.Match) + } +} diff --git a/internal/surface/cli/filing_match_surface_test.go b/internal/surface/cli/filing_match_surface_test.go new file mode 100644 index 000000000..27d7070bf --- /dev/null +++ b/internal/surface/cli/filing_match_surface_test.go @@ -0,0 +1,75 @@ +package cli + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/report" + "github.com/intentdriven/abcd/internal/gitutil" +) + +// filing_match_surface_test.go is the wiring proof for the filing-time match +// on the two unattended writers (itd-2609212137116617, iss-2609281911024185): +// `inbox promote` and `intent consistency ingest` run it through the layered +// configuration, and print what it found as the capture verb does. + +const ( + fmHeld = "The capture ledger reader silently skips a record whose frontmatter carries " + + "a duplicated key, so the finding disappears from every listing without a warning." + fmDouble = "Capture ledger reader silently skips any record whose frontmatter carries a " + + "duplicated key: the finding disappears from every listing, and no warning is printed." +) + +// TestInboxPromoteMatchesTheLedger: a promoted report that doubles an open +// record is filed with the link written, and the verb says so. +func TestInboxPromoteMatchesTheLedger(t *testing.T) { + repo, _ := gitRepoNoStore(t) + t.Chdir(repo) + held, err := capture.Capture(capture.CaptureRequest{ + RepoRoot: repo, Text: fmHeld, Severity: capture.SeverityMinor, Category: "bug", + Source: "user-observation", FoundDuring: "fixture", Remedy: issueschema.MachineRemedy, + }) + if err != nil { + t.Fatal(err) + } + skeleton := string(runCLI(t, "report", "--template")) + filed := string(runCLIStdin(t, fillTemplate(t, skeleton, "Ledger reader skips duplicated keys", fmDouble), "report", "-")) + i := strings.Index(filed, "rpt-") + if i < 0 { + t.Fatalf("report did not name an id:\n%s", filed) + } + id := filed[i : i+20] + t.Cleanup(report.SetAbcdRootCommitForTest(gitutil.RootCommit(repo))) + + out := string(runCLI(t, "inbox", "promote", id)) + if !strings.Contains(out, "matched "+held.ID) || !strings.Contains(out, "link written") { + t.Fatalf("promote does not report the match on %s:\n%s", held.ID, out) + } +} + +// TestIntentConsistencyIngestReportsTheMatch: every finding the pass files +// carries the filing-time match's outcome, in the text render and in --json. +func TestIntentConsistencyIngestReportsTheMatch(t *testing.T) { + repo := consistencyCLIRepo(t) + var em consistencyEmitted + if err := json.Unmarshal(runCLI(t, "intent", "consistency", "--json"), &em); err != nil { + t.Fatal(err) + } + fp := consistencyFindingsFile(t, repo, em) + var res struct { + Rows []struct { + Match *struct { + Threshold float64 `json:"threshold"` + } `json:"match"` + } `json:"rows"` + } + if err := json.Unmarshal(runCLI(t, "intent", "consistency", "ingest", "--findings-json", fp, "--json"), &res); err != nil { + t.Fatal(err) + } + if len(res.Rows) != 1 || res.Rows[0].Match == nil || res.Rows[0].Match.Threshold == 0 { + t.Fatalf("ingest --json rows = %+v; want the match outcome on the filed row", res.Rows) + } +} diff --git a/internal/surface/cli/intent_consistency.go b/internal/surface/cli/intent_consistency.go index 2d0705b19..abacaceb1 100644 --- a/internal/surface/cli/intent_consistency.go +++ b/internal/surface/cli/intent_consistency.go @@ -84,10 +84,19 @@ func newIntentConsistencyCommand(asJSON *bool) *cobra.Command { if err != nil { return &exitError{Code: 2, Msg: "abcd intent consistency ingest: " + fsutil.RedactHome(err.Error())} } - res, err := capture.IngestConsistency(repoRoot, payload, "") + // The filing-time match (itd-2609212137116617) on every record the + // pass files, configured as the capture verb's is and never a + // refusal: a refused configuration files unmatched and says why. + mc, matchRefused := resolveMatch(cmd.ErrOrStderr(), "intent consistency ingest", repoRoot) + res, err := capture.IngestConsistency(repoRoot, payload, "", mc) if err != nil { return &exitError{Code: 2, Msg: "abcd intent consistency ingest: " + fsutil.RedactHome(err.Error())} } + for i := range res.Rows { + if res.Rows[i].Match == nil && !res.Rows[i].Linked { + res.Rows[i].Match = matchRefused + } + } return render(cmd.OutOrStdout(), *asJSON, withReceipt(res, route, payload), func(w io.Writer) { fmt.Fprintf(w, "abcd intent consistency ingest — %s (receipt %s, scope %s)\n", res.Status, res.ReceiptID, res.Scope) dirty := "" @@ -104,6 +113,7 @@ func newIntentConsistencyCommand(asJSON *bool) *cobra.Command { } fmt.Fprintf(w, " %d. %s (%s) — %s %s: %s\n", r.Number, r.ClassLabel(), r.Severity, r.IssueID, how, termsafe.Sanitize(r.Summary)) + renderMatch(w, r.Match) } } renderReceiptLine(w, route, payload) diff --git a/internal/surface/cli/report.go b/internal/surface/cli/report.go index 6358628f2..51311ec61 100644 --- a/internal/surface/cli/report.go +++ b/internal/surface/cli/report.go @@ -346,15 +346,22 @@ func newInboxCommand(asJSON *bool) *cobra.Command { if err != nil { return &exitError{Code: 2, Msg: "abcd inbox promote: " + termsafe.Sanitize(err.Error()) + " (nothing written)"} } - p, err := report.Promote(ledger, args[0]) + // The filing-time match (itd-2609212137116617), configured as the + // capture verb's is and never a refusal. + mc, matchRefused := resolveMatch(cmd.ErrOrStderr(), "inbox promote", ledger) + p, err := report.Promote(ledger, args[0], mc) if err != nil { return reportRefusal("inbox promote", err, "nothing written") } + if p.Match == nil && !p.Resumed { + p.Match = matchRefused + } return render(cmd.OutOrStdout(), *asJSON, p, func(w io.Writer) { fmt.Fprintf(w, "promoted %s to %s — %s\n", p.Report, p.Capture, termsafe.Sanitize(p.Path)) if p.Resumed { fmt.Fprintln(w, " finished an earlier promotion that filed this capture; nothing new was filed") } + renderMatch(w, p.Match) fmt.Fprintln(w, " the report is kept in the inbox, marked promoted") if p.Redacted > 0 { fmt.Fprintf(w, " redacted %d span(s) before writing (home paths and identifiers are never committed)\n", p.Redacted) From 9a546b484fc6fde88b4ed920a38332666733429d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:10:00 +0100 Subject: [PATCH 45/69] chore(issues): note two of three filing-match routes landed The inbox promote and consistency ingest routes run the filing-time match on this branch. The record stays open: the reading-ingest route waits on ruling DQ2b. Refs: iss-2609281911024185 Assisted-by: Claude:claude-opus-5-5 --- ...td-2609212137116617-s-press-release-says-a-new-issue-is.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.abcd/work/issues/open/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md b/.abcd/work/issues/open/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md index 5553ac7ec..ad5f2f1e0 100644 --- a/.abcd/work/issues/open/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md +++ b/.abcd/work/issues/open/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md @@ -19,3 +19,7 @@ itd-2609212137116617's press release says a new issue is matched against the rec ## Remedy grounds (2026-09-29) This keeps itd-2609212137116617's shipped promise instead of narrowing it, and the unattended writers are the ones the intent's Grounds name as the reason for the match. Rejected: narrowing the press release to the two verbs, which is the product thinker's text and would leave the doubles unlinked. + +## Progress (2026-09-30) + +Two of the three routes landed on branch feat/filing-duplicate-every-route: inbox promote and the consistency ingest now run the filing-time match through CaptureRequest.Match, on the report's own title and prose and on the finding's summary and explanation. The reading-ingest route waits on ruling DQ2b, so this record stays open. From 415f8eb8f2645c0810a11ce4eb120d97e8a6233c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:10:57 +0100 Subject: [PATCH 46/69] fix(oracle): skip a repository's route to a keyed provider with a warning Ruling CD2 of 2026-09-29: a repository route to a provider that holds a key no longer fails every command that loads the provider configuration. readRoutes skips it (skipKeyedRoute) with one diagnostic naming the route, the provider, the credential's name and where to set the route instead, and the machine's own route to the same name, if it has one, applies in its place. Every other route loads. The security property of ruling AA(b) holds: a keyed provider is still unreachable through a repository route, and it is judged before the model, so the keyed provider's list is never consulted on the repository's behalf. A route the denylist matches keeps its hard refusal from any layer. `abcd ahoy credential` bare prints the configuration read's diagnostics on stderr and goes on; `abcd ahoy --providers` already renders them. The ADR's 2026-09-29 consequence, itd-2609081951381895 Decision 8 and the configuration chapter are amended to the skip. Captured, not fixed here: `ahoy connect` and the bare `ahoy` gap check drop these diagnostics. Refs: iss-2609300805090515 Assisted-by: Claude:claude-opus-5-5 --- .../brief/05-internals/03-configuration.md | 10 +- ...serves-only-the-models-it-lists-under-a.md | 7 +- ...compatible-api-oracle-adapter-the-first.md | 2 +- ...guration-s-diagnostics-oracle-apiconfig.md | 15 +++ commands/ahoy.md | 4 +- internal/core/oracle/config.go | 56 +++++++-- internal/core/oracle/config_test.go | 107 ++++++++++++++---- internal/surface/cli/ahoy_credential.go | 5 + internal/surface/cli/ahoy_credential_test.go | 41 +++++++ 9 files changed, 208 insertions(+), 39 deletions(-) create mode 100644 .abcd/work/issues/open/iss-2609300805090515-the-provider-configuration-s-diagnostics-oracle-apiconfig.md diff --git a/.abcd/development/brief/05-internals/03-configuration.md b/.abcd/development/brief/05-internals/03-configuration.md index b1f653ba7..11d02113b 100644 --- a/.abcd/development/brief/05-internals/03-configuration.md +++ b/.abcd/development/brief/05-internals/03-configuration.md @@ -126,9 +126,13 @@ rather than skipped: route the person set up on their own machine may spend their paid key (the product thinker's ruling AA(b) of 2026-09-29), so a repository's `.abcd/config.json` pointing a role or a judgement type at such a provider is - refused, naming the route, `~/.abcd/config.json` as where to set it, and the - repository's file as where to remove it, since the repository's route wins - per name over the machine's. A + skipped, with one diagnostic on stderr naming the route, `~/.abcd/config.json` + as where to set it, and the repository's file as where to remove it (the + technical facilitator's ruling CD2 of 2026-09-29). The rest of the + configuration loads, so every other route and every command that reads it + keeps working, and the machine's own route to that name, if it has one, + applies in its place. A route the denylist matches is refused whichever + provider it names. A provider holds a key when its block names `key`, judged from the block and never by reading the credential store. A repository's route to a provider whose block names no key (a local server) is admitted and wins over the diff --git a/.abcd/development/decisions/adrs/2609221009491186-a-provider-adapter-serves-only-the-models-it-lists-under-a.md b/.abcd/development/decisions/adrs/2609221009491186-a-provider-adapter-serves-only-the-models-it-lists-under-a.md index c169e1047..ed13b5b9c 100644 --- a/.abcd/development/decisions/adrs/2609221009491186-a-provider-adapter-serves-only-the-models-it-lists-under-a.md +++ b/.abcd/development/decisions/adrs/2609221009491186-a-provider-adapter-serves-only-the-models-it-lists-under-a.md @@ -77,8 +77,11 @@ We will make every provider adapter default-deny by model. repository from spending it too. Only a route the person set up on their own machine may use their paid key, so a role or a judgement type the repository's configuration points at a provider whose block names a key is - refused when the configuration is read, naming `~/.abcd/config.json` as where - to set it. A repository's route to a provider that holds no key (a local + skipped when the configuration is read, with a diagnostic naming + `~/.abcd/config.json` as where to set it; the rest of the configuration + loads, and the machine's own route to that name applies in its place (the + technical facilitator's ruling CD2 of 2026-09-29 chose the skip over + refusing the whole configuration). A repository's route to a provider that holds no key (a local server) and a `--route` the person types are unaffected. This reverses the route half of itd-2609081951381895 Decision 8, which said a route "may sit in either layer"; that decision is amended in the same change. The diff --git a/.abcd/development/intents/planned/itd-2609081951381895-abcd-ships-an-openai-compatible-api-oracle-adapter-the-first.md b/.abcd/development/intents/planned/itd-2609081951381895-abcd-ships-an-openai-compatible-api-oracle-adapter-the-first.md index 8ba36ab8a..c423774d2 100644 --- a/.abcd/development/intents/planned/itd-2609081951381895-abcd-ships-an-openai-compatible-api-oracle-adapter-the-first.md +++ b/.abcd/development/intents/planned/itd-2609081951381895-abcd-ships-an-openai-compatible-api-oracle-adapter-the-first.md @@ -65,7 +65,7 @@ Taken in the implementing lane (autonomous run A, 2026-09-26), within the ruling 5. **The external and keychain homes are deferred to the credential store (2026-09-26).** The key is read through the interim credential source, `internal/core/credential` (`~/.abcd/credentials.json`, mode 0600, owner-only, no symlink), which reads one home. This lane builds the write for that home alone, the abcd-only one. The environment-variable-or-external-tool home and the platform keychain (the `security` command on macOS, the secret service on Linux) are built by itd-2609221017023290, the credential store, which replaces the source's backing and not its interface; until it lands, `abcd ahoy connect` refuses either home naming itd-2609221017023290, before any call and any write, and the explanation names all three homes with the keychain recommended in prose. A fourth answer, `none`, sets up a local server that takes no key. 6. **The walkthrough is a verb the person runs, with the key on stdin (2026-09-26).** `ahoy` explains the adapter as an optional gap and on `abcd ahoy --providers`, and the setup is `abcd ahoy connect <provider>`, rather than a question the install pass asks: the install prompter echoes every answer into its transcript, a host's question tool would put the key in an agent's context, a flag would leave it in the process listing and the shell history, and a terminal would echo it as it is typed. Declining is not running it, and changes nothing. 7. **Verify, then write (2026-09-26).** The verification call is made with the key in memory before anything is written, and a failed verification writes nothing, so a wrong key or an unlisted model never leaves a half-configured provider behind. -8. **A provider block sits on the machine alone (2026-09-26), and so does a route to a provider that holds a key (amended 2026-09-29).** `oracle.api.<provider>` names the address a key is sent to, so a repository's `.abcd/config.json` declaring it is refused; a checkout must never be able to aim the person's key at a server of its choosing. Denylist extensions (`oracle.denylist`) may sit in either layer. A route (`oracle.roles.<agent>`, `oracle.judgements.<type>`) to a provider that holds a key sits on the machine alone: only a route the person set up on their own machine may spend their paid key, so a repository's route to such a provider is refused when the configuration is read, naming the route and `~/.abcd/config.json` as where to set it. A provider holds a key when its block names a `key` credential; that is judged from the block alone, never by reading the credential store, so no secret is read to decide it and a block that names a key counts as keyed before its key is stored. A repository's route to a provider whose block names no key (a local server) is admitted and wins per name, a `--route` the person types is unaffected, and a route naming a provider this machine has not configured stays on the host with a diagnostic. **Amendment, 2026-09-29:** the route half of this decision as ruled on 2026-09-26 said routes "may sit in either layer"; the product thinker's ruling AA(b) of 2026-09-29 reverses that for a provider that holds a key, and this text is the decision as it now stands (adr-2609221009491186 carries the consequence). +8. **A provider block sits on the machine alone (2026-09-26), and so does a route to a provider that holds a key (amended 2026-09-29).** `oracle.api.<provider>` names the address a key is sent to, so a repository's `.abcd/config.json` declaring it is refused; a checkout must never be able to aim the person's key at a server of its choosing. Denylist extensions (`oracle.denylist`) may sit in either layer. A route (`oracle.roles.<agent>`, `oracle.judgements.<type>`) to a provider that holds a key sits on the machine alone: only a route the person set up on their own machine may spend their paid key, so a repository's route to such a provider is skipped when the configuration is read, with a diagnostic naming the route and `~/.abcd/config.json` as where to set it, and the rest of the configuration loads, the machine's own route to that name applying in its place (the technical facilitator's ruling CD2 of 2026-09-29 chose the skip over refusing the whole configuration; a route the denylist matches is still refused). A provider holds a key when its block names a `key` credential; that is judged from the block alone, never by reading the credential store, so no secret is read to decide it and a block that names a key counts as keyed before its key is stored. A repository's route to a provider whose block names no key (a local server) is admitted and wins per name, a `--route` the person types is unaffected, and a route naming a provider this machine has not configured stays on the host with a diagnostic. **Amendment, 2026-09-29:** the route half of this decision as ruled on 2026-09-26 said routes "may sit in either layer"; the product thinker's ruling AA(b) of 2026-09-29 reverses that for a provider that holds a key, and this text is the decision as it now stands (adr-2609221009491186 carries the consequence). 9. **A provider claims no tier (2026-09-26).** A provider is reached by a role or a judgement type pointed at `<provider>/<model>`, or by a `--route` naming it, never by a tier alone, so `Connections.Serves` answers false for every tier. The bundled denylist is `anthropic/*`, the minimum ruled, and a reported model it matches discards the answer. ## Open Questions diff --git a/.abcd/work/issues/open/iss-2609300805090515-the-provider-configuration-s-diagnostics-oracle-apiconfig.md b/.abcd/work/issues/open/iss-2609300805090515-the-provider-configuration-s-diagnostics-oracle-apiconfig.md new file mode 100644 index 000000000..634faee0a --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300805090515-the-provider-configuration-s-diagnostics-oracle-apiconfig.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300805090515" +slug: "the-provider-configuration-s-diagnostics-oracle-apiconfig" +severity: "minor" +category: "ux" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/oracle/connect.go" +remedy: "Carry the configuration read's diagnostics out of oracle.Connect on its result (a field the JSON omits, or a named diagnostics member) and print each on stderr in the ahoy connect front door, the way ahoy credential does; give detectProviderAdapter a non-required gap naming each skipped route. Grounds: the APIConfig doc comment already says the diagnostics are for a front door to print on stderr, and ruling CD2 asks for the skip to be said; a test per door with a repository route to a keyed provider shows it." +--- + +The provider configuration's diagnostics (oracle.APIConfig.Diagnostics: a role outside the roster, a route to an unconfigured provider, and a repository's route to a keyed provider skipped under ruling CD2) reach the person only through 'abcd ahoy --providers' (its board) and 'abcd ahoy credential' bare (stderr). 'abcd ahoy connect' (oracle.Connect loads the configuration and drops them) and the bare 'abcd ahoy' provider-adapter gap (detectProviderAdapter) say nothing, so a skipped route is silent there. diff --git a/commands/ahoy.md b/commands/ahoy.md index 4a0205ffd..97542f4e1 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -470,7 +470,9 @@ Every external credential abcd holds lives in one store, in the home the person chooses once per credential. Bare, the sub-verb lists each credential an adapter reads (`hosting.cloudflare` for the site setup, each configured provider's key) with its `state` (`set`, `not set`, or a refusal) and `home`; -never a value. With a name it explains that credential and writes nothing: +never a value. A route the configuration read skips (a repository's route to +a provider that holds a key) is named on stderr and the listing goes on: relay +that line too. With a name it explains that credential and writes nothing: relay `unlocks`, `without_it`, then `homes_prose` verbatim (it recommends the platform keychain in the prose; never present one home as the marked option), then the `homes` and the `setup` command for each. diff --git a/internal/core/oracle/config.go b/internal/core/oracle/config.go index 0a884bd64..94c0b1b00 100644 --- a/internal/core/oracle/config.go +++ b/internal/core/oracle/config.go @@ -295,9 +295,11 @@ func denial(e DenyEntry) string { } // readRoutes reads one route family (roles or judgement types) from the repo -// and machine layers, the higher layer winning per name. A winning route from -// any layer but the machine's that names a provider holding a key is refused -// (keyed): only the person's own machine may point a route at their key. +// and machine layers, the higher layer winning per name. A route from any layer +// but the machine's that names a provider holding a key is skipped with a +// diagnostic (skipKeyedRoute), and the machine's own route to the name, if it +// has one, wins in its place: only the person's own machine may point a route +// at their key. func (c *APIConfig) readRoutes(s *layered.Stack, key string, into map[string]Target) error { names := map[string]bool{} for _, l := range []layered.Layer{layered.Repo, layered.Machine} { @@ -326,6 +328,12 @@ func (c *APIConfig) readRoutes(s *layered.Stack, key string, into map[string]Tar if err != nil { return fmt.Errorf("oracle adapter: %w", err) } + // A repository's route to a provider that holds a key is skipped, and + // the next layer's route to the name, the machine's own, applies in its + // place (ruling CD2 of 2026-09-29). + for len(found) > 0 && c.skipKeyedRoute(key, name, found[0]) { + found = found[1:] + } if len(found) == 0 { continue } @@ -349,12 +357,6 @@ func (c *APIConfig) readRoutes(s *layered.Stack, key string, into map[string]Tar "it runs on the host, as it would with no provider configured", where, layered.BoundKey(provider))) continue } - if p := c.providers[provider]; win.Layer != layered.Machine && keyed(p) { - return fmt.Errorf("oracle adapter: %s points at %s, a provider that holds a key (its block in %s names the credential %s); "+ - "only a route set on this machine may spend that key, so a repository's route to it is refused before any call: "+ - "set %s.%s in %s and remove it from %s, or point it at a provider whose block names no key", - where, layered.BoundKey(text), p.Origin, p.Key, key, name, layered.Config.MachineOrigin(), win.Origin) - } if err := c.Admit(provider, model); err != nil { return fmt.Errorf("oracle adapter: %s points at %s, %w", where, layered.BoundKey(text), err) } @@ -363,6 +365,42 @@ func (c *APIConfig) readRoutes(s *layered.Stack, key string, into map[string]Tar return nil } +// skipKeyedRoute reports whether one layer's route to name is a route from any +// layer but the machine's to a configured provider that holds a key, and says +// so in a diagnostic when it is. Only a route the person set up on their own +// machine may spend their paid key (ruling AA(b) of 2026-09-29), and such a +// route is skipped rather than refusing the whole configuration (ruling CD2 of +// the same day), so every other route and every command that reads the +// configuration keeps working. It is judged before the model is checked, so a +// keyed provider's list is never consulted on a repository's behalf; a route +// the denylist matches is left to the refusal below, which no layer softens, +// and a route this reader cannot parse is too. +func (c *APIConfig) skipKeyedRoute(key, name string, fd layered.Found) bool { + if fd.Layer == layered.Machine { + return false + } + text, err := layered.Decode[string](fd.Raw) + if err != nil { + return false + } + provider, model, ok := strings.Cut(text, "/") + if !ok || provider == "" || model == "" { + return false + } + p, configured := c.providers[provider] + if !configured || !keyed(p) { + return false + } + if _, denied := Denied(c.denylist, model); denied { + return false + } + c.Diagnostics = append(c.Diagnostics, fmt.Sprintf("oracle adapter: %s (%s layer): %s.%s points at %s, a provider that holds a key "+ + "(its block in %s names the credential %s); only a route set on this machine may spend that key, so this route is skipped "+ + "and the rest of the configuration applies: set %s.%s in %s and remove it from %s, or point it at a provider whose block names no key", + fd.Origin, fd.Layer, key, name, layered.BoundKey(text), p.Origin, p.Key, key, name, layered.Config.MachineOrigin(), fd.Origin)) + return true +} + // keyed reports whether p holds a key: whether its block names a credential. // It is judged from the block alone and never from the credential store, so no // secret is read to answer it, and a key stored or removed later cannot change diff --git a/internal/core/oracle/config_test.go b/internal/core/oracle/config_test.go index c56b6510c..fc71e16a2 100644 --- a/internal/core/oracle/config_test.go +++ b/internal/core/oracle/config_test.go @@ -124,8 +124,8 @@ func TestAnUnlistedModelIsRefusedWhenTheConfigurationIsRead(t *testing.T) { } } // A route in the repository is held to the machine's list the same way. The - // provider is keyless: a repository's route to a keyed one is refused before - // its list is consulted (TestARepositoryRouteToAKeyedProviderIsRefused). + // provider is keyless: a repository's route to a keyed one is skipped before + // its list is consulted (TestARepositoryRouteToAKeyedProviderIsSkipped). f := newFx(t) f.machineConfig(`{"oracle":{"api":{` + localBlock + `}}}`) f.repoConfig(`{"oracle":{"roles":{"scribe":"local/openai/gpt-5"}}}`) @@ -138,39 +138,100 @@ func TestAnUnlistedModelIsRefusedWhenTheConfigurationIsRead(t *testing.T) { // block naming no credential. const localBlock = `"local":{"base_url":"http://localhost:11434/v1","models":["qwen/qwen3-8b"]}` -// TestARepositoryRouteToAKeyedProviderIsRefused is the product thinker's -// ruling AA(b) of 2026-09-29: only a route the person set up on their own -// machine may spend their paid key, so a role or a judgement type the -// repository's configuration points at a provider that holds a key is refused -// when the configuration is read, naming the route, the provider and the -// machine's file as where the route is set. A provider holds a key when its -// block names one; the credential store is never consulted, so no secret is -// read to decide it. A machine route to the same name does not rescue the -// repository's: the repository's is the one that would win, so it is refused. -func TestARepositoryRouteToAKeyedProviderIsRefused(t *testing.T) { - for name, tc := range map[string]struct{ repo, machine, setting string }{ - "role": {repo: `"roles":{"scribe":"openrouter/typesafe/jev-1.13"}`, setting: "oracle.roles.scribe"}, +// TestARepositoryRouteToAKeyedProviderIsSkipped is the product thinker's +// ruling AA(b) of 2026-09-29 as ruling CD2 of the same day shapes it: only a +// route the person set up on their own machine may spend their paid key, so a +// role or a judgement type the repository's configuration points at a provider +// that holds a key never reaches it. The route is skipped, with one diagnostic +// naming the route, the provider and the machine's file as where the route is +// set, and the rest of the configuration loads: every other route keeps +// working, and a machine route to the same name is the one that applies. A +// provider holds a key when its block names one; the credential store is never +// consulted, so no secret is read to decide it. +func TestARepositoryRouteToAKeyedProviderIsSkipped(t *testing.T) { + for name, tc := range map[string]struct { + repo, machine, setting string + family, route string + machineModel string + }{ + "role": {repo: `"roles":{"scribe":"openrouter/typesafe/jev-1.13"}`, setting: "oracle.roles.scribe", + family: rolesKey, route: "scribe"}, "judgement type": {repo: `"judgements":{"duplicate-match":"openrouter/typesafe/jev-latest"}`, - setting: "oracle.judgements.duplicate-match"}, + setting: "oracle.judgements.duplicate-match", family: judgementsKey, route: "duplicate-match"}, "role over a machine route": {repo: `"roles":{"scribe":"openrouter/typesafe/jev-latest"}`, - machine: `,"roles":{"scribe":"openrouter/typesafe/jev-1.13"}`, setting: "oracle.roles.scribe"}, - "unlisted model": {repo: `"roles":{"scribe":"openrouter/openai/gpt-5"}`, setting: "oracle.roles.scribe"}, + machine: `,"roles":{"scribe":"openrouter/typesafe/jev-1.13"}`, setting: "oracle.roles.scribe", + family: rolesKey, route: "scribe", machineModel: "typesafe/jev-1.13"}, + "unlisted model": {repo: `"roles":{"scribe":"openrouter/openai/gpt-5"}`, setting: "oracle.roles.scribe", + family: rolesKey, route: "scribe"}, } { t.Run(name, func(t *testing.T) { f := newFx(t) - f.machineConfig(`{"oracle":{"api":{` + openrouterBlock + `}` + tc.machine + `}}`) - f.repoConfig(`{"oracle":{` + tc.repo + `}}`) - err := f.loadAPIErr() - for _, want := range []string{".abcd/config.json (repo layer)", tc.setting, "openrouter", "holds a key", + f.machineConfig(`{"oracle":{"api":{` + openrouterBlock + `,` + localBlock + `}` + tc.machine + `}}`) + // A keyless route beside the refused one: the skip costs nothing else. + other := `"roles":{"scribe":"local/qwen/qwen3-8b"}` + if tc.family == rolesKey { + other = `"judgements":{"duplicate-match":"local/qwen/qwen3-8b"}` + } + f.repoConfig(`{"oracle":{` + tc.repo + `,` + other + `}}`) + c, err := LoadAPI(f.roots) + if err != nil { + t.Fatalf("LoadAPI refused the whole configuration over one repository route: %v", err) + } + var hits []string + for _, d := range c.Diagnostics { + if strings.Contains(d, "holds a key") { + hits = append(hits, d) + } + } + if len(hits) != 1 { + t.Fatalf("diagnostics %q, want exactly one naming the skipped keyed route", c.Diagnostics) + } + for _, want := range []string{".abcd/config.json (repo layer)", tc.setting, "openrouter", "holds a key", "skipped", "set " + tc.setting + " in ~/.abcd/config.json and remove it from .abcd/config.json,"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("refusal %q does not name %q", err, want) + if !strings.Contains(hits[0], want) { + t.Errorf("diagnostic %q does not name %q", hits[0], want) + } + } + var got Target + var ok bool + if tc.family == rolesKey { + got, ok = c.Role(tc.route) + if tgt, on := c.Judgement("duplicate-match"); !on || tgt.Provider != "local" { + t.Errorf("the other route = %+v, %v; want it loaded", tgt, on) + } + } else { + got, ok = c.Judgement(tc.route) + if tgt, on := c.Role("scribe"); !on || tgt.Provider != "local" { + t.Errorf("the other route = %+v, %v; want it loaded", tgt, on) + } + } + switch { + case tc.machineModel != "": + if !ok || got.Model != tc.machineModel || got.Origin != "~/.abcd/config.json" { + t.Errorf("%s = %+v, %v; want the machine's own route", tc.route, got, ok) } + case ok: + t.Errorf("%s = %+v; a repository route to a keyed provider must never load", tc.route, got) } }) } } +// TestADenylistedKeyedRepositoryRouteIsStillRefused: skipping a repository's +// route to a keyed provider never softens the denylist. A route the denylist +// matches refuses the configuration from any layer, keyed provider or not. +func TestADenylistedKeyedRepositoryRouteIsStillRefused(t *testing.T) { + f := newFx(t) + f.machineConfig(`{"oracle":{"api":{` + openrouterBlock + `}}}`) + f.repoConfig(`{"oracle":{"roles":{"scribe":"openrouter/anthropic/claude-opus-4"}}}`) + err := f.loadAPIErr() + for _, want := range []string{"anthropic/*", "vendor denylist", "oracle.roles.scribe"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("refusal %q does not name %q", err, want) + } + } +} + // TestAMachineRouteToAKeyedProviderIsAdmitted: the machine's own route to a // provider that holds a key is the person's, and loads as it always did. func TestAMachineRouteToAKeyedProviderIsAdmitted(t *testing.T) { diff --git a/internal/surface/cli/ahoy_credential.go b/internal/surface/cli/ahoy_credential.go index 4ea2d147c..e4a64e43f 100644 --- a/internal/surface/cli/ahoy_credential.go +++ b/internal/surface/cli/ahoy_credential.go @@ -158,6 +158,11 @@ func runCredentialList(cmd *cobra.Command, roots layered.Roots, asJSON bool) err if err != nil { return &exitError{Code: 2, Msg: "abcd ahoy credential: " + termsafe.Sanitize(fsutil.RedactHome(err.Error()))} } + // A route the read skipped (a repository's route to a provider that holds + // a key, ruling CD2) is said on stderr, and the listing goes on. + for _, d := range cfg.Diagnostics { + fmt.Fprintf(cmd.ErrOrStderr(), "abcd %s\n", termsafe.Sanitize(fsutil.RedactHome(d))) + } for _, p := range cfg.Providers() { if p.Key != "" { names[p.Key] = true diff --git a/internal/surface/cli/ahoy_credential_test.go b/internal/surface/cli/ahoy_credential_test.go index bc1cef751..f7d5cf13c 100644 --- a/internal/surface/cli/ahoy_credential_test.go +++ b/internal/surface/cli/ahoy_credential_test.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "encoding/json" "os" "path/filepath" @@ -160,3 +161,43 @@ func TestAhoyCredentialRefusesAFailedVerification(t *testing.T) { t.Fatal("a refused key was stored") } } + +// TestARepositoryRouteToAKeyedProviderIsSkippedWithAWarning is ruling CD2 of +// 2026-09-29 at the front doors that read the provider configuration: a +// repository's route to a provider that holds a key no longer refuses the +// command. The route is skipped with one warning on stderr naming the route +// and why, and the command does its work. +func TestARepositoryRouteToAKeyedProviderIsSkippedWithAWarning(t *testing.T) { + hermeticEnv(t) + providerNamingKey(t, "https://openrouter.ai/api/v1") + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".abcd", "config.json"), + []byte(`{"oracle":{"roles":{"scribe":"openrouter/typesafe/jev-1.13"}}}`), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + + root := NewRootCommand() + root.SetArgs([]string{"ahoy", "credential"}) + var so, se bytes.Buffer + root.SetOut(&so) + root.SetErr(&se) + if err := root.Execute(); err != nil { + t.Fatalf("ahoy credential refused over one repository route: %v\n%s%s", err, so.String(), se.String()) + } + if !strings.Contains(so.String(), "openrouter") { + t.Errorf("ahoy credential did not list the provider's credential:\n%s", so.String()) + } + warn := se.String() + if n := strings.Count(warn, "holds a key"); n != 1 { + t.Fatalf("stderr carries %d keyed-route warning(s), want one:\n%s", n, warn) + } + for _, want := range []string{"oracle.roles.scribe", "openrouter/typesafe/jev-1.13", "skipped", "~/.abcd/config.json"} { + if !strings.Contains(warn, want) { + t.Errorf("the warning does not name %q:\n%s", want, warn) + } + } +} From 0f718f5ae62be8e6545aa0360094a7301d2c6eec Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:13:04 +0100 Subject: [PATCH 47/69] chore(issues): capture the round-5 IFS target regression reverify4-guardSet finding 1: the round-4 rule read the bytes written beside an expansion, while bash sets the variable a target's value names, so a target written as expansions alone names IFS unread. Refs: iss-2609300812525892 Assisted-by: Claude:claude-opus-5-5 --- ...s-2609300651115290-s-class-a-regression-the.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609300812525892-round-5-of-iss-2609300651115290-s-class-a-regression-the.md diff --git a/.abcd/work/issues/open/iss-2609300812525892-round-5-of-iss-2609300651115290-s-class-a-regression-the.md b/.abcd/work/issues/open/iss-2609300812525892-round-5-of-iss-2609300651115290-s-class-a-regression-the.md new file mode 100644 index 000000000..7c89ff243 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300812525892-round-5-of-iss-2609300651115290-s-class-a-regression-the.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300812525892" +slug: "round-5-of-iss-2609300651115290-s-class-a-regression-the" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/payload.go" +remedy: "Replace the byte trigger with one target rule: a line where any assignment target holds an expansion ($name, ${...} with transforms, $(...), backticks) reads its IFS as unknown, capped as a literal IFS assignment is; a target is the word before =/op= (spaced or not, save a test's lone = after [, test, &&) or beside ++/--, its name part without a subscript, plus the naming builtins' operands and a nameref declaration. Grounds: printf stand-in probes on bash 3.2 and 5.3 print [][/] for each form." +--- + +Round 5 of iss-2609300651115290's class, a regression the round-4 fix (84ad332c5) opened: the IFS rule keyed on the bytes written beside an expansion, but bash sets the variable an assignment target's VALUE names, so a target written as expansions alone names IFS with no I, F or S byte (reverify4-guardSet finding 1). a=I; b=FS; (( ${a}${b} = 1 )) and x=$a$b; (( $x = 1 )) set IFS to 1 on bash 3.2 and 5.3, and an unquoted default delete after them then hands rm the root; 282908797 blocked both, b993017e8 allows them. Siblings: (( ${x^^} = 1 )), (( ${x@P} = 1 )), a spaced operator in a string arithmetic reads (declare -i n; n="$y = 1"), a substring offset, ${!x:=1}, and a nameref (declare -n r=$x; r=1), which no round read. From 2d7ac0ad246617c6479e82603f64859542f8feab Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:13:13 +0100 Subject: [PATCH 48/69] fix(guard): read an assignment target holding an expansion as naming IFS bash sets the variable an assignment target's value names, so no rule over the bytes written beside an expansion can cover a target made of expansions alone: `(( ${a}${b} = 1 ))` with a=I, b=FS sets IFS. The byte trigger (buildsName, nameAt) is replaced by one target rule, targetsAnExpansion: a line where any assignment target holds an expansion reads its IFS as unknown, capped as a literal IFS assignment is. A target is the word before an assignment operator (`=` or a compound one, spaced or not) or beside `++`/`--`, its name part without a subscript, read in the raw text of every layer: assignment words, declaration and env operands, eval'd strings, every arithmetic context, strings arithmetic later reads, and `${!x:=1}`. namesIFS also reads a nameref's declaration (`declare -n r=$x`), whose value is the name a later plain assignment sets. The rule stays off a test's lone `=` after `[`, `[[`, `test`, `-a`, `-o`, `&&`, `||`, a word-leading `--` flag and the `--` that ends options. The 1099-line corpus verdicts are unchanged. Refs: iss-2609300812525892 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/17-guard.md | 43 +- commands/guard.md | 13 +- internal/core/guard/guardset_test.go | 67 +++- internal/core/guard/payload.go | 376 +++++++++++++----- internal/core/guard/tokenize.go | 6 +- internal/core/guard/unknownsites_test.go | 4 +- internal/termsafe/codespan_canonical_test.go | 2 +- 7 files changed, 371 insertions(+), 140 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/17-guard.md b/.abcd/development/brief/04-surfaces/17-guard.md index b1d2ac356..70cd36c2b 100644 --- a/.abcd/development/brief/04-surfaces/17-guard.md +++ b/.abcd/development/brief/04-surfaces/17-guard.md @@ -355,22 +355,31 @@ background, `$@`, `$*` and a positional one with no argument, `$_` after (`${PWD%%$!*}` is `${PWD%%*}`); a replacement's pattern is read both where bash 3.2 ends it and where bash 5 does, at a quoted `/` (`${X/"/"*/$HOME}`); and on a line that names IFS — in any word or -anywhere in its text (`: $((IFS=1))`), or through a name built from an -expansion — an unquoted default's or +anywhere in its text (`: $((IFS=1))`), or through an assignment target +that holds an expansion — an unquoted default's or alternative's word, and an unquoted home, reads as every target, since the fields bash splits it into rest on that IFS (`IFS=x; rm -rf ${U:-x/x}`, -`IFS=Uv; rm -rf $HOME/x`). A name built from an expansion is read by its -shape in the raw text of each layer, never by its context, since bash -assigns one in a declaration's or a `read`'s operand, an eval'd -assignment and every arithmetic context (`$[ ]`, a subscript, a substring -offset, `[[ -eq ]]`, an integer variable's value, a value an arithmetic -reference evaluates): an expansion beside an `I`, `F` or `S` byte -(`export ${I}FS=x`, `a[${I}FS=1]=x`, `eval "I${F:-F}S=x"`), one an -assignment operator follows (`${N}=x`, `$x += 1`, `$x++`) or `++` comes -before, and one standing whole as a name a declaration, a `read` or a -`printf -v` assigns (`printf -v "$n" x`). That shape over-reads on the -refusing side: `IFS=x rm -rf ${U:-x/x}`, and `read -p "$prompt" f;` or -`mkdir ${V}S;` before `rm -rf ${U:-x/x}`. A run of `/` written before the home names the +`IFS=Uv; rm -rf $HOME/x`). bash sets the variable a target's value names, +so the rule reads whether a target holds an expansion (`$name`, `${…}` +with its case changes and transforms, `$(…)`, a backtick substitution), +never which bytes are written beside it: `(( ${a}${b} = 1 ))` names IFS +with a=I and b=FS. A target is the word before an assignment operator +(`=` or a compound one, spaced or not) or beside a `++` or `--`, read in +the raw text of each layer, so it is found in every position bash assigns +through one: an assignment word's name, a declaration's or `env`'s operand, +an eval'd string, every arithmetic context (`(( ))`, `$(( ))`, `$[ ]`, a +`for (( ))` header, a subscript, a substring offset, `[[ -eq ]]`), a string +an arithmetic context later reads (`let "$x = 1"`, an integer variable's +value), and `${!x:=1}`, which sets the name x holds. A subscript is not +the name (`a[$i]=x` names `a`); its body is arithmetic and read as such. +The builtins that take a name as an operand (`read "$x"`, +`printf -v "$x" 1`, `mapfile`, `getopts`, `wait -p`) are read over their +words, and so is a nameref's declaration (`declare -n r=$x`, +`local -n r`), whose value is the name a later plain assignment sets. The +rule stays off a test's comparison (`[ $a = b ]`), a word-leading `--` +(`git log --$fmt`) and the `--` that ends options, and over-reads on the +refusing side: `IFS=x rm -rf ${U:-x/x}`, and `read -p "$prompt" f;`, +`echo "$k = $v";` or `[ ! $a = b ];` before `rm -rf ${U:-x/x}`. A run of `/` written before the home names the home (`/$HOME`). A trim that leaves the path above the home (`${HOME%/*}`) blocks as the home does. Each target is also compared as a path with its redundant separators taken out, since the kernel reads a run of @@ -433,8 +442,10 @@ table does not name; a REST path an entry names by its root segment when the host serves that API under a prefix; an IFS the shell already holds when the line starts, or gains during the line through a name the guard does not read (a sourced file, a nameref set before -the line, a name made of expansions alone such as `${a}${b}`, or the whole -value of a variable a command's output set, as in `x=$(cmd); : $((x))`), +the line, or an operator the line does not write: a value built from +expansions or a command's output that an arithmetic context evaluates, as in +`x=$(cmd); : $((x))`, or a decrement written as its own word in such a +string, as in `n="1 + --$x"` for an integer `n`), since every line is read from the default IFS; a pid list a kill reads through a variable or a file, or from a `ps | grep` chain; a payload inside a non-shell interpreter such as `python -c`, which is diff --git a/commands/guard.md b/commands/guard.md index 804070f5b..58a18178c 100644 --- a/commands/guard.md +++ b/commands/guard.md @@ -248,9 +248,10 @@ builds them: as the command in command position, as operands after it, and at every payload layer the guard follows, so a document whose own text is `$(cat <<'F' … F)` is read too. Text written in the word is not split, as bash does not split it, and an assignment's value is not split either. On a command -line where any other command names IFS (`IFS=x;`, `export IFS=x`, or a name built -from an expansion anywhere in the line's text, such as `export ${I}FS=x` or -`: $[${I}FS=1]`), an unquoted +line where any other command names IFS (`IFS=x;`, `export IFS=x`, or an +assignment target that holds an expansion anywhere in the line's text, such as +`export ${I}FS=x`, `(( ${a}${b} = 1 ))` or `printf -v "$x" 1`, since bash sets the +variable the target's value names), an unquoted fixed output is a **block** (`ifs-split-unread`): the guard splits on the default IFS only, and refuses rather than work out which assignment reaches which expansion. A prefix assignment (`IFS=x $(…)`) does not reach its own command's @@ -392,9 +393,9 @@ runs, or `pkill` or `killall` as the program a variable names (`$P make`) — because reading each would refuse the ordinary commands a variable carries a value for, an IFS the shell already holds when the line starts or gains during the line through a name the guard does not read -(a sourced file, a nameref set before the line, a name made of expansions -alone such as `${a}${b}`, the whole value of a variable a command's output set; -every line is read from the default IFS), a hazard inside a non-shell interpreter's payload (`python -c`, +(a sourced file, a nameref set before the line, or an operator the line does +not write, such as a command's output an arithmetic context evaluates; every +line is read from the default IFS), a hazard inside a non-shell interpreter's payload (`python -c`, `perl -e`) — one opaque token the tokenizer cannot read, today a silent allow, not a warn (a warn for it is a recorded design target, not yet implemented), or a dangerous form no entry describes. Nor does an allow see what a lone substitution diff --git a/internal/core/guard/guardset_test.go b/internal/core/guard/guardset_test.go index 56c15ed85..3405d2232 100644 --- a/internal/core/guard/guardset_test.go +++ b/internal/core/guard/guardset_test.go @@ -291,9 +291,8 @@ func TestIFSNamedThroughAMarkTheWrittenCompareReads(t *testing.T) { // TestANameBuiltFromAnExpansionTheWrittenCompareReads — reverify3-guardSet // finding 2, closed by one rule rather than one more context: a line that -// builds a name from an expansion anywhere (an expansion touching a name -// byte or another expansion, or standing where an assignment operator -// follows) reads its IFS as unknown. Every form sets IFS in bash 3.2, +// assigns through a target holding an expansion anywhere reads its IFS as +// unknown (targetsAnExpansion). Every form sets IFS in bash 3.2, // /bin/sh and bash 5.3, which then hand rm `""` and `/` for `${U:-1/1}`. func TestANameBuiltFromAnExpansionTheWrittenCompareReads(t *testing.T) { const home = "rm-rf-root-or-home" @@ -347,6 +346,68 @@ func TestANameBuiltFromAnExpansionTheWrittenCompareReads(t *testing.T) { }) } +// TestAnAssignmentTargetHoldingAnExpansionTheWrittenCompareReads — +// reverify4-guardSet finding 1. bash sets IFS from an assignment target's +// VALUE, so a target the line writes as expansions alone names IFS with no +// byte of the name written: `a=I; b=FS; (( ${a}${b} = 1 ))` sets IFS to 1, +// and `rm -rf ${U:-1/1}` then hands rm `""` and `/` on bash 3.2, /bin/sh +// and bash 5.3. The rule reads the target, not its bytes: a line where any +// assignment target holds an expansion reads its IFS as unknown. +func TestAnAssignmentTargetHoldingAnExpansionTheWrittenCompareReads(t *testing.T) { + const home = "rm-rf-root-or-home" + checkSpellingCases(t, []spellingCase{ + // The forms the round-4 byte rule let through. + {`a=I; b=FS; (( ${a}${b} = 1 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`a=I; b=FS; x=$a$b; (( $x = 1 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`x=ifs; (( ${x^^} = 1 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`(( ${x@P} = 1 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`let "$x=1"; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`declare -- "$x=1"; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`printf -v "$x" 1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`read -r "$x" <<< 1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`export "$x"=1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + // Every other position a target can take: a spaced operator in a + // string arithmetic reads (let, an integer's value), a substring + // offset, a ternary's arm, a for header, a subscript, a value an + // arithmetic reference evaluates, a decrement let reads, an + // indirect default, and a nameref, whose value is the name a plain + // assignment later sets (bash 5.3). + {`let "$x = 1"; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`declare -i n; n="$y = 1"; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`: ${X:$y = 1}; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`(( 1 ? $x = 1 : 0 )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`for (( $x = 1; 0; )); do :; done; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`a["$x = 1"]=y; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`: $(( a[$x = 1] )); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`y=$x=1; : $((y)); rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`let --$x; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`unset "$y"; : ${!x:=1}; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`declare -n r=$x; r=1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + {`local -n r; r=$x; r=1; rm -rf ${U:-1/1}`, shellBare | shellSQ, VerdictBlock, home}, + // The over-block the rule keeps: a flag's word read as a target, + // and a spaced `=` in a string. + {`read -p "$prompt" f; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`echo "$k = $v"; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + {`[ ! $a = b ]; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictBlock, home}, + // The look-alikes: each target is a literal name, the expansion in + // the value or the subscript. + {`n=$((n+1)); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`a[$i]=x; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`(( i++ )); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`(( n += $k )); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`(( a[$i] = 1 )); rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS= read -r f; rm -rf "$f"`, shellBare | shellSQ, VerdictAllow, ""}, + {`IFS=, read -ra arr <<< "$x"; rm -rf "${arr[0]}"`, shellBare | shellSQ, VerdictAllow, ""}, + {`export PATH=$HOME/bin:$PATH; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`git log --$fmt; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`mkdir ${V}S; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`[ $a = b ] && rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`[ "$a" = "$b" ] && rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`: ${X:=1}; rm -rf ${U:-x/x}`, shellBare | shellSQ, VerdictAllow, ""}, + {`declare -n r=arr; rm -rf "$f"`, shellBare | shellSQ, VerdictAllow, ""}, + }) +} + // TestColonDefaultsOfAnEmptyParameterTheWrittenCompareReads — // reverify-guardSet finding 2. With the colon, a default, an assignment and // an error message treat an empty parameter as unset, so `${1:-dist}` with diff --git a/internal/core/guard/payload.go b/internal/core/guard/payload.go index c58b2aad8..4d7613a8f 100644 --- a/internal/core/guard/payload.go +++ b/internal/core/guard/payload.go @@ -495,19 +495,20 @@ func wordFeeds(s segment, keep func(int) bool) []feed { // namesIFS reports whether segs name IFS, as splitAfterIFS and // capIFSSplits read a naming: any word that holds the name once its quotes // are read (`IFS=x`, `declare "I"'FS=x'`, `declare $'\x49FS=x'`), a text -// the tokenizer read that holds the name or builds a name from an -// expansion (segment.namesIFSInText, buildsName), and an expansion -// standing whole as a name a builtin assigns (`printf -v "$n" x`, +// the tokenizer read that holds the name or assigns through a target +// holding an expansion (segment.namesIFSInText, targetsAnExpansion), an +// expansion standing as a name a builtin assigns (`printf -v "$n" x`, // `read $v`, `declare $(cmd)=x`): namingCommands' operands, wherever they -// stand in the command, and the word after `printf -v` or `wait -p`. That -// last reading refuses on the side of a name: `read -p "$prompt" f` -// counts too. +// stand in the command, and the word after `printf -v` or `wait -p`, and a +// nameref's declaration (`declare -n r=$x`, `local -n r`), whose value is +// the name any later plain assignment to it sets. The builtin reading +// refuses on the side of a name: `read -p "$prompt" f` counts too. func namesIFS(segs []segment) bool { for _, s := range segs { if s.namesIFSInText { return true } - naming, target := false, false + naming, target, decl := false, false, false flag := "" for _, tok := range s.tokens { tally(len(tok)) @@ -518,8 +519,11 @@ func namesIFS(segs []segment) bool { return true } target = flag != "" && tok == flag + if decl && len(tok) > 1 && tok[0] == '-' && strings.IndexByte(tok, 'n') > 0 { + return true + } if namingCommands[tok] { - naming = true + naming, decl = true, declarations[tok] } if f, ok := targetFlags[tok]; ok { flag = f @@ -537,6 +541,9 @@ var namingCommands = map[string]bool{ "read": true, "mapfile": true, "readarray": true, "getopts": true, "let": true, } +// declarations are the builtins whose `-n` declares a nameref. +var declarations = map[string]bool{"declare": true, "typeset": true, "local": true} + // targetFlags names, per builtin, the flag whose word assigns the variable // it names: `printf -v NAME`, and bash 5.1's `wait -p NAME`. var targetFlags = map[string]string{"printf": "-v", "wait": "-p"} @@ -551,144 +558,293 @@ func nameMarked(tok string) bool { return strings.IndexByte(tok, unknownMark) >= 0 || strings.IndexByte(tok, varMark) >= 0 } -// buildsName reports whether text builds a name from an expansion, read -// lexically over the raw text of one layer, quotes, arithmetic bodies, -// subscripts and here-documents included (reverify3-guardSet finding 2). -// bash assigns a name so built in more places than can be listed: a -// declaration's or a `read`'s operand, an eval'd assignment word, and every -// arithmetic context (`$(( ))`, `(( ))`, `$[ ]`, `for (( ))`, a subscript, -// a substring offset, `[[ -eq ]]`, an integer variable's value, and a -// variable's value an arithmetic reference evaluates). So the rule reads -// the shape and never the context: an expansion (`$name`, `${…}`, `$(…)`, -// `$[…]`, a backtick substitution, `$1`, `$@`, `$*`, or a mark a payload -// carries) that -// - touches an I, F or S byte: a name that is IFS and spans an -// expansion's edge has a byte of IFS written beside that edge, unless -// it is made of expansions alone; -// - stands as the target of an assignment: `=` follows it -// (`${N}=x`), a compound assignment operator follows it, spaced or not -// (`$x += 1`, `$x<<=1`), `++` or `--` follows it, or `++` comes -// before it. +// targetsAnExpansion reports whether text assigns through a target that +// holds an expansion, read lexically over the raw text of one layer, +// quotes, arithmetic bodies, subscripts and here-documents included. bash +// sets the variable a target's VALUE names, so a target holding an +// expansion (`$name`, `${…}` with its case changes and transforms, `$(…)`, +// `$((…))`, `$[…]`, a backtick substitution, or a mark a payload carries) +// can name IFS whatever the line writes beside it: `(( ${a}${b} = 1 ))` +// with a=I and b=FS sets IFS (reverify4-guardSet finding 1). The rule reads +// the target, never its bytes. // -// `$#`, `$?`, `$$`, `$!` and `$-` print numbers or option letters and are -// not read. What it does not read is a name whose text the line does not -// write: one made of expansions alone (`${a}${b}`), or the whole value of -// a variable a command's output set (`x=$(cmd); : $((x))`, `--$x`), which is -// the class of a sourced file's IFS, and a whole expansion a spaced `=` -// follows (`(( $x = 1 ))`), which reads as a test's comparison does. -func buildsName(text string) bool { +// A target is the word that stands before an assignment operator (`=`, +// `+=`, `-=`, `*=`, `/=`, `%=`, `<<=`, `>>=`, `&=`, `^=`, `|=`, and a +// parameter expansion's `:=`), or beside a `++` or `--`; its name part +// leaves out a subscript (`a[$i]=x` names a), while the subscript's own +// body is read as arithmetic. Every place bash assigns through such an +// operator is one of these shapes: an assignment word (a declaration's or +// env's operand included), an eval'd string, every arithmetic body +// (`((…))`, `$((…))`, `$[…]`, a for header, a subscript, a substring +// offset, `[[ -eq ]]`), a string an arithmetic context later reads (let's +// operand, an integer's value, a variable an arithmetic reference +// evaluates), and `${!x:=…}`, whose target is the name x holds. Arithmetic +// takes the operator spaced, so a blank between target and operator is +// read through, except where bash cannot be assigning: +// - a lone `=` whose target follows a test's word (`[`, `[[`, `test`, +// `-a`, `-o`, `&&`, `||`, `\(`) outside an arithmetic body is the +// test's comparison (`[ $a = b ]`); arithmetic refuses an assignment +// after any of those words; +// - a `--` that begins a word outside an arithmetic body is a flag +// (`git log --$fmt`), and a spaced `--` a blank follows there ends a +// command's options (`git checkout $b -- f`). +// +// The builtins that take a name as a whole operand (`read "$x"`, +// `printf -v "$x"`, a nameref's declaration) are read over the words +// (namesIFS). What it does not read is an operator the line does not write: +// a value built from expansions that an arithmetic context evaluates +// (`y=$a$b; : $((y))` with b holding `=1`), a command's output +// (`x=$(cmd); : $((x))`), and, by the second exception, a decrement written +// as its own word in such a string (`n="1 + --$x"` for an integer n). +func targetsAnExpansion(text string) bool { tally(len(text)) - type open struct { - closer byte - at int // the expansion's `$` or backtick; -1 for a bracket - } - var stack []open + type level struct { + closer byte // the byte that closes the level; 0 at the top + exp bool // an expansion: closing it adds one to the enclosing target + sub bool // a subscript: closing it leaves the enclosing target as it was + arith bool // an arithmetic body, where an operator takes its target spaced + run bool // a target is being read + runExp bool // its name part holds an expansion + prev bool // a blank ended a target, and nothing else has come since + prevExp bool + pre bool // a `++` or `--` stands before the next target + chunk int // where the blank-delimited word being read began; -1 between words + words [2]string + } + stack := []level{{chunk: -1}} + // pending counts the open levels per closer, so a closer none is open + // for costs nothing and the scan stays linear. var pending [4]int - slot := func(c byte) int { - return strings.IndexByte("})]`", c) + slot := func(c byte) int { return strings.IndexByte("})]`", c) } + push := func(lv level) { + lv.chunk = -1 + stack = append(stack, lv) + pending[slot(lv.closer)]++ } - push := func(c byte, at int) { - stack = append(stack, open{c, at}) - pending[slot(c)]++ + end := func(l *level) { + l.run, l.runExp, l.prev, l.prevExp, l.pre = false, false, false, false, false } - // closeAt closes the innermost open c at i, and reports whether the - // expansion it closes builds a name. - closeAt := func(c byte, i int) bool { + word := func(l *level) { + if !l.run { + l.run, l.runExp, l.prev = true, false, false + } + } + expand := func(l *level) bool { + word(l) + l.runExp = true + return l.pre + } + // closeAt closes the innermost open c, and reports whether it was open + // and whether the expansion it closes completes a target a `++` or `--` + // stands before. + closeAt := func(c byte) (open, hit bool) { if pending[slot(c)] == 0 { - return false + return false, false } - for { - top := stack[len(stack)-1] - stack = stack[:len(stack)-1] - pending[slot(top.closer)]-- - if top.closer == c { - return top.at >= 0 && nameAt(text, top.at, i+1) - } + k := len(stack) - 1 + for stack[k].closer != c { + pending[slot(stack[k].closer)]-- + k-- + } + pending[slot(c)]-- + lv := stack[k] + stack = stack[:k] + out := &stack[k-1] + switch { + case lv.exp: + return true, expand(out) + case !lv.sub: + end(out) } + return true, false } for i := 0; i < len(text); i++ { - switch c := text[i]; { + l := &stack[len(stack)-1] + c := text[i] + if isBlank(c) { + if l.run { + l.prev, l.prevExp = true, l.runExp + l.run, l.runExp, l.pre = false, false, false + } + if l.chunk >= 0 { + l.words[0], l.words[1] = l.words[1], text[l.chunk:i] + l.chunk = -1 + } + continue + } + if l.chunk < 0 { + l.chunk = i + } + switch { case c == '\\': + word(l) i++ + case c == '"' || c == '\'': + // A quote is part of the word it stands in. case c == unknownMark || c == varMark: - if nameAt(text, i, i+1) { + if expand(l) { return true } case c == '`': - if pending[slot('`')] > 0 { - if closeAt('`', i) { - return true - } - } else { - push('`', i) + open, hit := closeAt('`') + if hit { + return true + } + if !open { + push(level{closer: '`', exp: true}) } case c == '$' && i+1 < len(text): switch n := text[i+1]; { - case n == '{': - push('}', i) - i++ + case n == '(' && i+2 < len(text) && text[i+2] == '(': + push(level{closer: ')', exp: true, arith: true}) + push(level{closer: ')', arith: true}) + i += 2 case n == '(': - push(')', i) + push(level{closer: ')', exp: true}) + i++ + case n == '{': + lv := level{closer: '}', exp: true, arith: true} + if i+2 < len(text) && text[i+2] == '!' { + // `${!x:=1}` assigns the name x holds. + lv.run, lv.runExp = true, true + i++ + } + push(lv) i++ case n == '[': - push(']', i) + push(level{closer: ']', exp: true, arith: true}) i++ - case isNameStart(n): - j := i + 1 - for j < len(text) && isNameByte(text[j]) { - j++ - } - if nameAt(text, i, j) { - return true - } - i = j - 1 - case n >= '0' && n <= '9' || n == '@' || n == '*': - if nameAt(text, i, i+2) { + case isNameByte(n) || strings.IndexByte("@*#?$!-", n) >= 0: + if expand(l) { return true } i++ + default: + word(l) } - case c == '{': - push('}', -1) case c == '(': - push(')', -1) - case c == '}' || c == ')' || c == ']': - if closeAt(c, i) { + arith := l.arith + end(l) + if !arith && i+1 < len(text) && text[i+1] == '(' { + push(level{closer: ')', arith: true}) + i++ + arith = true + } + push(level{closer: ')', arith: arith}) + case c == '{': + arith := l.arith + end(l) + push(level{closer: '}', arith: arith}) + case c == '[': + if l.run { + push(level{closer: ']', sub: true, arith: true}) + } else { + end(l) + } + case c == ')' || c == '}' || c == ']': + open, hit := closeAt(c) + if hit { return true } + if !open { + end(l) + } + case isNameByte(c): + word(l) + case strings.IndexByte("=+-*/%&|^<>!:", c) >= 0: + kind, n := operatorAt(text, i) + spaced := !l.run && l.prev + target := l.run && l.runExp || spaced && l.prevExp + switch kind { + case opAssign: + test := c == '=' && spaced && !l.arith && (i+1 == len(text) || isBlank(text[i+1])) && + testWords[strings.TrimLeft(l.words[0], `$'"\`)] + if target && !test { + return true + } + end(l) + case opStep: + options := !l.arith && c == '-' && spaced && i+2 < len(text) && isBlank(text[i+2]) + if target && !options { + return true + } + flag := !l.arith && c == '-' && l.chunk == i + end(l) + l.pre = !flag + default: + end(l) + } + i += n - 1 + default: + end(l) } } return false } -// nameAt reports whether the expansion text[start:end] builds a name, by -// buildsName's shapes. -func nameAt(text string, start, end int) bool { - if start > 0 && strings.IndexByte("IFS", text[start-1]) >= 0 { - return true - } - if start >= 2 && text[start-2:start] == "++" { - return true - } - if end >= len(text) { - return false - } - rest := text[end:] - switch { - case strings.IndexByte("IFS", rest[0]) >= 0: - return true - case strings.HasPrefix(rest, "++") || strings.HasPrefix(rest, "--"): - return true - case rest[0] == '=': - // `=` counts only against the expansion: spaced, it is also a - // test's comparison (`[ $a = b ]`). - return !strings.HasPrefix(rest, "==") - } - rest = strings.TrimLeft(rest, " \t") - if strings.HasPrefix(rest, "<<=") || strings.HasPrefix(rest, ">>=") { - return true +// testWords are the words a test's comparison can follow (`[ $a = b ]`, +// `test "$a" = b`, `[[ $a = b && $c = d ]]`), read past the quotes a +// string that holds the test opens with (`bash -c '[ $a = b ]'`). Arithmetic takes none of them +// before an assignment: `(( 1 && $x = 1 ))` is an error, while `!` and `(` +// are not in the set because `(( ! $x = 1 ))` (bash 3.2) and +// `(( ( $x = 1 ) ))` assign. +var testWords = map[string]bool{ + "[": true, "[[": true, "test": true, "-a": true, "-o": true, "&&": true, "||": true, `\(`: true, +} + +// The operator kinds operatorAt reads. +const ( + opOther = iota // no assignment: a comparison, a redirection, a pattern + opAssign // `=` or a compound assignment + opStep // `++` or `--` +) + +// operatorAt reads the operator that begins at text[i], and returns its kind +// and its length. +func operatorAt(text string, i int) (kind, n int) { + c := text[i] + var next byte + if i+1 < len(text) { + next = text[i+1] + } + switch c { + case '=': + if next == '=' || next == '~' { + return opOther, 2 + } + return opAssign, 1 + case '<', '>': + if next == c { + if i+2 < len(text) && text[i+2] == '=' { + return opAssign, 3 + } + return opOther, 2 + } + if next == '=' { + return opOther, 2 + } + case '+', '-': + if next == c { + return opStep, 2 + } + if next == '=' { + return opAssign, 2 + } + case '!': + if next == '=' { + return opOther, 2 + } + default: // * / % & | ^ : + if next == '=' { + return opAssign, 2 + } } - return len(rest) >= 2 && strings.IndexByte("+-*/%&|^", rest[0]) >= 0 && rest[1] == '=' + return opOther, 1 +} + +// isBlank reports whether c separates the words of a shell line. +func isBlank(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' } // capIFSSplits reads each word of segs whose fields rest on the default IFS diff --git a/internal/core/guard/tokenize.go b/internal/core/guard/tokenize.go index 4eef8b838..907feae17 100644 --- a/internal/core/guard/tokenize.go +++ b/internal/core/guard/tokenize.go @@ -93,8 +93,8 @@ type segment struct { // (iss-2609290321312087). nil when no word holds a variable. spelled map[int][]string // namesIFSInText records that the text a tokenize call read holds the - // name IFS or builds a name from an expansion (buildsName), wherever it - // stands: an arithmetic body or a subscript leaves no word to read it + // name IFS or assigns through a target that holds an expansion + // (targetsAnExpansion), wherever it stands: an arithmetic body or a subscript leaves no word to read it // in. It rides on an empty segment of its own, as substitutionUnread // does, and namesIFS reads it. namesIFSInText bool @@ -1758,7 +1758,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { if len(pending) > 0 { markHeredocUnterminated(&segs, chain) } - if depth == 0 && (unwordedIFS(line, segs) || buildsName(line)) { + if depth == 0 && (unwordedIFS(line, segs) || targetsAnExpansion(line)) { // The text of every substitution depth is in the line read at // depth 0, so it is read once, there. segs = append(segs, segment{chain: chain, namesIFSInText: true}) diff --git a/internal/core/guard/unknownsites_test.go b/internal/core/guard/unknownsites_test.go index aec7272c7..2072b36f4 100644 --- a/internal/core/guard/unknownsites_test.go +++ b/internal/core/guard/unknownsites_test.go @@ -56,7 +56,9 @@ var wordReaders = map[string]string{ "splitStringValue": "commandArrivals and nameCouldBe", "scanEnvSplits": "readWord, flagCouldBe and clusterCouldCarry on every unknown word", "launcherPayloads": "readWord on every word", - "nameAt": "exempt: reads the raw text around an expansion for an assignment operator (`++`, `-=`), never a command word or a flag", + "targetsAnExpansion": "exempt: reads the raw text for an assignment target that holds an expansion (`--` a decrement or a flag), never a command word's flag", + "operatorAt": "exempt: reads an assignment or step operator (`--`, `-=`) in the raw text, never a command word or a flag", + "namesIFS": "exempt: reads a declaration's literal nameref flag (`-n`); a flag word holding an expansion is already read as a name the builtin assigns (nameMarked)", "guessedEvalPayload": "exempt: reads eval's literal `--`; vanishable drops a word that may print nothing", "evalPayload": "exempt: reads eval's literal `--`, which no substitution spells (the rule's terminator clause)", "shellCPayloads": "clusterCouldCarry on every word", diff --git a/internal/termsafe/codespan_canonical_test.go b/internal/termsafe/codespan_canonical_test.go index a339acdd6..05cc1af68 100644 --- a/internal/termsafe/codespan_canonical_test.go +++ b/internal/termsafe/codespan_canonical_test.go @@ -32,7 +32,7 @@ var backtickScanners = map[string]backtickScanner{ "internal/adapter/scanner/identity.go": {1, "a delimiter set: a backtick is one of the characters that may end an identity token; nothing is paired"}, "internal/core/capture/promote.go": {1, "a WRITER: codeSpan measures the longest backtick run to choose a fence the value cannot close; nothing is paired"}, "internal/core/guard/tokenize.go": {23, "the shell tokenizer: a backtick there is command substitution, a shell grammar, not markdown"}, - "internal/core/guard/payload.go": {4, "buildsName reads a shell line's raw text for a name an expansion builds: a backtick there opens or closes a command substitution, a shell grammar, not markdown; nothing is paired"}, + "internal/core/guard/payload.go": {3, "targetsAnExpansion reads a shell line's raw text for an assignment target that holds an expansion: a backtick there opens or closes a command substitution, a shell grammar, not markdown; nothing is paired"}, "internal/core/guard/unknown.go": {2, "spellWord spells a default's or an alternative's shell word, and readPattern reads a trim's or a replacement's pattern: a backtick in either opens a command substitution, whose output the spelling drops or the pattern reads as unknown text; nothing is paired"}, "internal/core/history/reconstruct_render.go": {1, "a WRITER: longestBacktickRun sizes a fence longer than any run in the body; nothing is paired"}, "internal/core/ideate/render.go": {1, "blockText asks whether a value opens with a backtick, then asks OpensBalancedCodeSpan, which pairs through PairCodeSpan"}, From 5649f5362d70d5ce7cb8398cdbca219be0416253 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:13:28 +0100 Subject: [PATCH 49/69] =?UTF-8?q?chore:=20resolve=20iss-2609300812525892?= =?UTF-8?q?=20=E2=80=94=20the=20round-5=20IFS=20target=20regression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609300812525892 Assisted-by: Claude:claude-opus-5-5 --- ...-5-of-iss-2609300651115290-s-class-a-regression-the.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609300812525892-round-5-of-iss-2609300651115290-s-class-a-regression-the.md (66%) diff --git a/.abcd/work/issues/open/iss-2609300812525892-round-5-of-iss-2609300651115290-s-class-a-regression-the.md b/.abcd/work/issues/resolved/iss-2609300812525892-round-5-of-iss-2609300651115290-s-class-a-regression-the.md similarity index 66% rename from .abcd/work/issues/open/iss-2609300812525892-round-5-of-iss-2609300651115290-s-class-a-regression-the.md rename to .abcd/work/issues/resolved/iss-2609300812525892-round-5-of-iss-2609300651115290-s-class-a-regression-the.md index 7c89ff243..bc5cabf83 100644 --- a/.abcd/work/issues/open/iss-2609300812525892-round-5-of-iss-2609300651115290-s-class-a-regression-the.md +++ b/.abcd/work/issues/resolved/iss-2609300812525892-round-5-of-iss-2609300651115290-s-class-a-regression-the.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/payload.go" remedy: "Replace the byte trigger with one target rule: a line where any assignment target holds an expansion ($name, ${...} with transforms, $(...), backticks) reads its IFS as unknown, capped as a literal IFS assignment is; a target is the word before =/op= (spaced or not, save a test's lone = after [, test, &&) or beside ++/--, its name part without a subscript, plus the naming builtins' operands and a nameref declaration. Grounds: printf stand-in probes on bash 3.2 and 5.3 print [][/] for each form." +resolution: "Fixed by 2d7ac0ad2: targetsAnExpansion reads whether any assignment target holds an expansion, in every position bash assigns through an operator, and namesIFS reads a nameref's declaration; the byte trigger is removed." +impact: fix +resolved_by: + commit: "2d7ac0ad2" --- Round 5 of iss-2609300651115290's class, a regression the round-4 fix (84ad332c5) opened: the IFS rule keyed on the bytes written beside an expansion, but bash sets the variable an assignment target's VALUE names, so a target written as expansions alone names IFS with no I, F or S byte (reverify4-guardSet finding 1). a=I; b=FS; (( ${a}${b} = 1 )) and x=$a$b; (( $x = 1 )) set IFS to 1 on bash 3.2 and 5.3, and an unquoted default delete after them then hands rm the root; 282908797 blocked both, b993017e8 allows them. Siblings: (( ${x^^} = 1 )), (( ${x@P} = 1 )), a spaced operator in a string arithmetic reads (declare -i n; n="$y = 1"), a substring offset, ${!x:=1}, and a nameref (declare -n r=$x; r=1), which no round read. + +## Grounds + +- pursued: every pinned form (expansion-only arithmetic targets, case change and transform values, let/declare/printf -v/read/export operands, spaced string operators, substring offset, indirect default, nameref) blocks in TestAnAssignmentTargetHoldingAnExpansionTheWrittenCompareReads while n=$((n+1)), a[$i]=x, [ $a = b ] and git log --$fmt stay allowed and the 1099-line corpus verdicts are unchanged; a target written with an expansion that still sets IFS unread would show it wrong. From b6f27a4006c4d62ebfac07e74e29c0c9a3ab5bde Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:26:50 +0100 Subject: [PATCH 50/69] fix(intent): settle a blocker replaced by an accepted ADR or a discipline Rulings CF1 and CF2 of 2026-09-30: a blocker whose supersession chain ends at an accepted decision, and a blocker reclassified as a discipline, count as settled at the build's blocked pre-start check, so the waiting intent may start. followBlocker now ends a chain at an ADR successor by reading that decision's status. The file is found through the record-id resolver (recordid.LookupOne, which routes both ADR id vintages by filename) and read through the shared frontmatter field reader, with the file's own id confirmed as the record dispatch confirms it; no second parser. Status accepted settles the edge and the passing row names the chain; any other status, no status, or a decision this checkout does not hold refuses and names it. startBlockedRow counts a final record in disciplines/ as settled beside shipped/. The board's next up reads the same StartChecksIn seam, so it follows with no second copy; a board test proves it. The build help text, the generated CLI reference, commands/build.md, commands/abcd.md and the brief chapters 34-build and 08-abcd say "unsettled blocker" and describe both endings. Refs: iss-2609300751191426 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/08-abcd.md | 2 +- .../development/brief/04-surfaces/34-build.md | 23 ++- commands/abcd.md | 2 +- commands/build.md | 17 +- docs/reference/cli/commands.md | 4 +- internal/core/intent/startcheck.go | 164 +++++++++++++----- internal/core/intent/startcheck_test.go | 81 +++++++-- internal/core/statusblock/statusblock_test.go | 59 +++++++ internal/surface/cli/build.go | 4 +- 9 files changed, 278 insertions(+), 78 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index 7f7a4dd23..a06439d55 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -232,7 +232,7 @@ by the pick's one rule (`intent.PickLess`), the readiest first and the oldest among equals. The head is the first of them the pick would start: one that passes the build's record-only pre-start checks, read through the one statement of them the build runs too (`intent.StartChecksIn`: no open question, -the claim sections answered, no hold, no unshipped blocker, a step left to +the claim sections answered, no hold, no unsettled blocker, a step left to build), and not one the state file shows in a lane, as the pick passes over an intent with a run in progress. The build's peers check is not run: the block reads no other checkout, so an intent a peer holds can still be the head. The diff --git a/.abcd/development/brief/04-surfaces/34-build.md b/.abcd/development/brief/04-surfaces/34-build.md index 5d7bbccd6..ffc8fc9c9 100644 --- a/.abcd/development/brief/04-surfaces/34-build.md +++ b/.abcd/development/brief/04-surfaces/34-build.md @@ -62,15 +62,20 @@ No run is created until every check passes, and each is a read (criteria 1 and 2 ask what the record meant. - **hold** — the record carries no `held:`, well formed or not (iss-2609200830076665). -- **blocked** — nothing the record names in `blocked_by` is unshipped: an - intent outside `shipped/`, or one this checkout's store does not hold, blocks - it (itd-2609211116005482). A blocker in `superseded/` is followed along its - `superseded_by` to the intent that replaced it, transitively, and the record - waits on that replacement: it blocks exactly when the last intent of the - chain has not shipped (ruling BZ2 of 2026-09-29). A chain that loops, names a - record this checkout does not hold, stops at a superseded record naming no - successor, or ends at a decision (`adr-N`) rather than an intent blocks, and - the reason names the chain. +- **blocked** — nothing the record names in `blocked_by` is unsettled: an + intent in `shipped/` or `disciplines/` is settled (ruling CF2 of 2026-09-30: + a discipline is a standing rule, not work that ships), and an intent anywhere + else, or one this checkout's store does not hold, blocks it + (itd-2609211116005482). A blocker in `superseded/` is followed along its + `superseded_by` to the record that replaced it, transitively, and the record + waits on that replacement: it blocks exactly when the last record of the + chain is unsettled (ruling BZ2 of 2026-09-29). A chain ending at a decision + (`adr-N`) is settled when that ADR's status is `accepted` (ruling CF1 of + 2026-09-30), read through the record-id resolver both ADR id vintages route + by; a decision in any other status, or one this checkout does not hold, + blocks. A chain that loops, names an intent this checkout does not hold, or + stops at a superseded record naming no successor blocks too. A refusal names + the chain; a settled chain is named in the passing row. - **steps** — the open spec's `## Steps`, read through the spec store's own reader, parses and leaves at least one step unlanded. A spec listing no steps is one step, the whole spec. diff --git a/commands/abcd.md b/commands/abcd.md index 9d04bfd07..c610ef30c 100644 --- a/commands/abcd.md +++ b/commands/abcd.md @@ -89,7 +89,7 @@ Next and the `next_up` intent are read in `abcd build next`'s pick order (`order` is `pick`): the readiest first by the pick's score, the oldest among equals, and the head passes over an intent that `abcd build next` refuses from the record alone (an open question, an -unanswered claim section, a hold, an unshipped blocker, no step left to build) +unanswered claim section, a hold, an unsettled blocker, no step left to build) or that is already in a lane. The head does not consult other checkouts, so an intent a peer holds can still be marked `next_up`. Relay Now first: it is what is being built and what comes next. The block is computed each time and nothing stores it. diff --git a/commands/build.md b/commands/build.md index d9d160795..e159d218f 100644 --- a/commands/build.md +++ b/commands/build.md @@ -43,13 +43,16 @@ pass: - `claim_sections` — the `## Mechanism` prompt is answered (or the section absent) and the scope conditions are recorded. - `hold` — the intent carries no `held:`. -- `blocked` — nothing the intent names in `blocked_by` is unshipped (an intent - not in `shipped/`, or one this checkout does not hold, blocks it). A - superseded blocker is followed along `superseded_by` to the intent that - replaced it, transitively, and blocks only while that replacement is - unshipped; a chain that loops, ends at a record this checkout does not hold - or at a decision (`adr-N`), or stops at a superseded record naming no - successor blocks, naming the chain. +- `blocked` — nothing the intent names in `blocked_by` is unsettled. An intent + in `shipped/` or `disciplines/` is settled; one anywhere else, or one this + checkout does not hold, blocks it. A superseded blocker is followed along + `superseded_by` to the record that replaced it, transitively, and blocks only + while that replacement is unsettled: an intent settles as above, and a + decision (`adr-N`) settles when its status is `accepted`. A decision in any + other status or missing from this checkout blocks, as does a chain that + loops, ends at an intent this checkout does not hold, or stops at a + superseded record naming no successor; the reason names the chain, and a + settled chain is named in the passing row. - `steps` — the spec's `## Steps` reads, and at least one step is not landed. - `peers` — no peer holds the intent: no sibling worktree or local branch holds it in another bucket, and no session other than `--session` holds a live diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index ba501dd52..7518911ae 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -230,7 +230,7 @@ Start the loop that takes one READY intent to delivered: Writes the run's state Start the implement loop for one intent, or resume the run already in progress for it. A new run's checks run first, and every one must pass: the intent is READY (planned, criteria written, its spec linked and written), asks no -open question, has no unanswered claim section, is not held, names no unshipped intent +open question, has no unanswered claim section, is not held, names no unsettled blocker in `blocked_by`, its spec leaves a step to build, and no peer holds it (no sibling worktree or local branch holds it in another bucket, and no session holds a live claim on it; a peer or claim that cannot be read counts as holding it). A refusal names the @@ -290,7 +290,7 @@ Pick the readiest planned intent and start its run: Writes the run's state and t Pick the readiest planned intent, write down why, and start its run. The candidates are the planned intents that pass every check `abcd build <itd-N>` runs -(READY, no open question, no unanswered claim section, not held, no unshipped intent in +(READY, no open question, no unanswered claim section, not held, no unsettled blocker in `blocked_by`, a step left to build, no peer holding it), less one this checkout already has a run in progress for. Each is scored from its record, three parts at equal weight, each 0 to 100: criteria clarity (the share of its acceptance criteria in Given-When-Then form), a diff --git a/internal/core/intent/startcheck.go b/internal/core/intent/startcheck.go index 4d08c2e48..9c4e7faab 100644 --- a/internal/core/intent/startcheck.go +++ b/internal/core/intent/startcheck.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strings" + "github.com/intentdriven/abcd/internal/core/frontmatter" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/core/spec" ) @@ -72,7 +73,11 @@ func StartChecksIn(repoRoot string, corpus Corpus, store spec.Store, r ReadyResu res.Rows = append(res.Rows, startClaimSectionsRow(r, content)) it, _ := corpus.Lookup(r.IntentID) res.Rows = append(res.Rows, startHoldRow(it)) - res.Rows = append(res.Rows, startBlockedRow(corpus, r.IntentID, content)) + blockedRow, err := startBlockedRow(repoRoot, corpus, r.IntentID, content) + if err != nil { + return res, err + } + res.Rows = append(res.Rows, blockedRow) stepsRow, steps, err := startStepsRow(repoRoot, store, r) if err != nil { return res, err @@ -140,80 +145,157 @@ func startHoldRow(it Intent) StartRow { return row } -// startBlockedRow refuses a record that names, in `blocked_by`, an intent that has -// not shipped. A blocker the corpus does not hold is unshipped as far as this +// startBlockedRow refuses a record that names, in `blocked_by`, a blocker that +// is not settled. An intent is settled when it has shipped or sits in +// disciplines/ (ruling CF2 of 2026-09-30: a blocker reclassified as a +// discipline is a standing rule, not work that will ever ship, so it counts as +// settled). A blocker the corpus does not hold is unsettled as far as this // checkout can tell, and refuses too: the edge says something must ship first. // -// A blocker that was superseded is followed along `superseded_by` to the intent +// A blocker that was superseded is followed along `superseded_by` to the record // that replaced it, transitively, and the record waits on that replacement -// (ruling BZ2 of 2026-09-29): it is blocked exactly when the last intent of the -// chain has not shipped. A chain the check cannot finish refuses, naming the -// chain: one that loops, one whose next record this checkout does not hold, a -// superseded record naming no successor, and one ending at a decision (adr-N), -// because a decision replacing the work is not an intent that ships and nothing -// on the record says the edge is settled by it. -func startBlockedRow(corpus Corpus, id, content string) StartRow { +// (ruling BZ2 of 2026-09-29): it is blocked exactly when the last record of the +// chain is unsettled. A chain ending at a decision (adr-N) is settled when that +// ADR's status is `accepted` (ruling CF1 of 2026-09-30); a decision in any other +// status, or one this checkout's decision store does not hold, refuses naming +// it. A chain the check cannot finish refuses, naming the chain: one that +// loops, one whose next intent this checkout does not hold, and a superseded +// record naming no successor. A settled chain passes and names itself, so the +// row says which record settled the edge. An error is a fault in reading the +// checkout. +func startBlockedRow(repoRoot string, corpus Corpus, id, content string) (StartRow, error) { row := StartRow{Name: StartCheckBlocked} var open, followed []string for _, b := range blockedBy(content) { - chain, final, problem := followBlocker(corpus, b) - path := strings.Join(chain, " → ") + end, err := followBlocker(repoRoot, corpus, b) + if err != nil { + return row, err + } + path := strings.Join(end.chain, " → ") switch { - case problem != "" && len(chain) == 1: - open = append(open, b+" ("+problem+")") - case problem != "": - open = append(open, b+" (superseded: "+path+": "+problem+")") - case final.Bucket != BucketShipped && len(chain) == 1: - open = append(open, b+" ("+final.Bucket+")") - case final.Bucket != BucketShipped: - open = append(open, b+" (superseded: "+path+", "+final.Bucket+")") - case len(chain) > 1: - followed = append(followed, path+" (shipped)") + case end.problem != "" && len(end.chain) == 1: + open = append(open, b+" ("+end.problem+")") + case end.problem != "": + open = append(open, b+" (superseded: "+path+": "+end.problem+")") + case !end.settled && len(end.chain) == 1: + open = append(open, b+" ("+end.state+")") + case !end.settled: + open = append(open, b+" (superseded: "+path+", "+end.state+")") + case len(end.chain) > 1: + followed = append(followed, path+" ("+end.state+")") } } if len(open) == 0 { row.OK = true - row.Detail = id + " names no unshipped blocker" + row.Detail = id + " names no unsettled blocker" if len(followed) > 0 { - row.Detail += "; a superseded blocker waits on its replacement: " + strings.Join(followed, ", ") + row.Detail += "; a superseded blocker is settled by the record that replaced it: " + strings.Join(followed, ", ") } - return row + return row, nil } row.Detail = id + " is blocked by " + strings.Join(open, ", ") - row.Remedy = "ship the blocker first, or the intent its supersession chain ends at (each superseded record names its successor in `superseded_by`; repair a chain that loops or ends nowhere); or drop the edge from `blocked_by` if it no longer holds" - return row + row.Remedy = "ship the blocker first, or settle the record its supersession chain ends at: ship that intent, or accept that decision (`status: accepted`); each superseded record names its successor in `superseded_by`, so repair a chain that loops or ends nowhere; or drop the edge from `blocked_by` if it no longer holds" + return row, nil +} + +// blockerEnd is where one blocker's supersession chain ends. chain names every +// record visited, the blocker first. When problem is empty, state names the +// last record's standing (its intent bucket, or `accepted` for a decision) and +// settled says whether that standing releases the edge; when problem is +// non-empty the chain could not be finished and refuses. +type blockerEnd struct { + chain []string + state string + settled bool + problem string } // followBlocker walks one blocker along `superseded_by` until it reaches a -// record that is not superseded. chain names every record visited, the blocker -// first; final is the record the chain ends at, and problem is non-empty when -// the chain cannot be finished (then final is meaningless). -func followBlocker(corpus Corpus, blocker string) (chain []string, final Intent, problem string) { - chain = []string{blocker} +// record that is not a superseded intent: an intent in any other bucket, which +// settles the edge from shipped/ or disciplines/, or a decision, which settles +// it when accepted. An error is a fault in reading the decision store. +func followBlocker(repoRoot string, corpus Corpus, blocker string) (blockerEnd, error) { + end := blockerEnd{chain: []string{blocker}} seen := map[string]bool{} cur := blocker for { it, ok := corpus.Lookup(cur) if !ok { - return chain, Intent{}, "not in this checkout's intent store" + end.problem = "not in this checkout's intent store" + return end, nil } if seen[it.ID] { - return chain, Intent{}, "a supersession cycle" + end.problem = "a supersession cycle" + return end, nil } seen[it.ID] = true if it.Bucket != BucketSuperseded { - return chain, it, "" + end.state = it.Bucket + end.settled = it.Bucket == BucketShipped || it.Bucket == BucketDisciplines + return end, nil } next := it.SupersededBy if next == "" { - return chain, Intent{}, it.ID + " is superseded and names no successor" + end.problem = it.ID + " is superseded and names no successor" + return end, nil + } + if recordid.ValidIntentID(next) { + end.chain = append(end.chain, next) + cur = next + continue } - chain = append(chain, next) - if !recordid.ValidIntentID(next) { - return chain, Intent{}, next + " is not an intent: a decision replaced the blocker, and nothing on the record says that settles the edge" + adr := recordid.CanonADRID(next) + if adr == "" { + end.chain = append(end.chain, next) + end.problem = next + " names neither an intent nor a decision" + return end, nil } - cur = next + end.chain = append(end.chain, adr) + status, found, err := decisionStatus(repoRoot, adr) + switch { + case err != nil: + return end, err + case !found: + end.problem = adr + " is not in this checkout's decision store" + case status == "": + end.problem = adr + " carries no status: a decision settles the edge once its status is accepted" + case status != adrStatusAccepted: + end.problem = adr + " is " + status + ": a decision settles the edge once its status is accepted" + default: + end.state = status + end.settled = true + } + return end, nil + } +} + +// adrStatusAccepted is the ADR status that puts a decision in force. +const adrStatusAccepted = "accepted" + +// decisionStatus reads the `status` of the decision canonical names (a +// canonical ADR id). The file is found through the record-id resolver, which +// routes both ADR id vintages by filename (recordid.ADRFileID), and read through +// the shared frontmatter field reader; the file's own `id` must name the same +// decision, as the `abcd adr-N` dispatch confirms it, or the decision counts as +// absent. found is false when this checkout's decision store holds no such +// record; status is "" when the record carries none. An error is a fault in +// reading the store or the file. +func decisionStatus(repoRoot, canonical string) (status string, found bool, err error) { + rel, ok, err := recordid.LookupOne(repoRoot, canonical) + if err != nil || !ok { + return "", false, err + } + data, err := readRepoFile(filepath.Join(repoRoot, filepath.FromSlash(rel)), rel) + if err != nil { + return "", false, err + } + fields := frontmatter.Fields(strings.Split(string(data), "\n")) + got, _ := frontmatter.ScalarString(frontmatter.StripComment(fields["id"].Value)) + if recordid.CanonADRID(got) != canonical { + return "", false, nil } + status, _ = frontmatter.ScalarString(frontmatter.StripComment(fields["status"].Value)) + return strings.TrimSpace(status), true, nil } // startStepsRow reads the open spec's steps through the spec store's reader: the diff --git a/internal/core/intent/startcheck_test.go b/internal/core/intent/startcheck_test.go index 6d8997f41..a52a7b659 100644 --- a/internal/core/intent/startcheck_test.go +++ b/internal/core/intent/startcheck_test.go @@ -4,6 +4,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/intentdriven/abcd/internal/core/decide" ) // blockerRecord is a minimal intent record for the blocked check's corpus: an @@ -16,52 +18,92 @@ func blockerRecord(id, supersededBy string) string { return s + "---\n# " + id + "\n" } +// adrRecord is a minimal decision record: its id and the status line's value. +func adrRecord(id, status string) string { + return "---\nid: " + id + "\nslug: a-decision\nstatus: " + status + "\n---\n# " + id + "\n" +} + // TestStartBlockedRowFollowsASupersededBlockerToItsReplacement is ruling BZ2 // of 2026-09-29: a blocker that was superseded is followed along // `superseded_by` to the intent that replaced it, transitively, and the intent -// waits on that replacement. It is blocked exactly when the last intent of the -// chain has not shipped; a chain that loops, ends at a record this checkout -// does not hold, names no successor, or ends at a decision rather than an -// intent refuses naming the chain. +// waits on that replacement. It is blocked exactly when the last record of the +// chain is unsettled; a chain that loops, ends at a record this checkout does +// not hold, or names no successor refuses naming the chain. Rulings CF1 and CF2 +// of 2026-09-30 settle two more endings: a chain ending at a decision settles +// when that ADR is accepted, and one ending at a discipline settles as a +// shipped intent does; a decision in any other status, or one this checkout +// does not hold, still refuses naming it. func TestStartBlockedRowFollowsASupersededBlockerToItsReplacement(t *testing.T) { type rec struct{ bucket, id, by string } cases := []struct { name string records []rec + adrs map[string]string // ADR filename -> its frontmatter ok bool want []string }{ {"replaced by a shipped intent", []rec{ {BucketSuperseded, "itd-27", "itd-94"}, {BucketShipped, "itd-94", ""}, - }, true, []string{"itd-27 → itd-94"}}, + }, nil, true, []string{"itd-27 → itd-94"}}, {"replaced by an unshipped intent", []rec{ {BucketSuperseded, "itd-27", "itd-94"}, {BucketPlanned, "itd-94", ""}, - }, false, []string{"itd-27 → itd-94", "planned"}}, + }, nil, false, []string{"itd-27 → itd-94", "planned"}}, {"replaced twice, the last shipped", []rec{ {BucketSuperseded, "itd-27", "itd-94"}, {BucketSuperseded, "itd-94", "itd-95"}, {BucketShipped, "itd-95", ""}, - }, true, []string{"itd-27 → itd-94 → itd-95"}}, + }, nil, true, []string{"itd-27 → itd-94 → itd-95"}}, {"replaced twice, the last a draft", []rec{ {BucketSuperseded, "itd-27", "itd-94"}, {BucketSuperseded, "itd-94", "itd-95"}, {BucketDrafts, "itd-95", ""}, - }, false, []string{"itd-27 → itd-94 → itd-95", "drafts"}}, + }, nil, false, []string{"itd-27 → itd-94 → itd-95", "drafts"}}, {"a supersession cycle", []rec{ {BucketSuperseded, "itd-27", "itd-94"}, {BucketSuperseded, "itd-94", "itd-27"}, - }, false, []string{"itd-27 → itd-94 → itd-27", "cycle"}}, + }, nil, false, []string{"itd-27 → itd-94 → itd-27", "cycle"}}, {"a replacement this checkout does not hold", []rec{ {BucketSuperseded, "itd-27", "itd-94"}, - }, false, []string{"itd-27 → itd-94", "not in this checkout's intent store"}}, + }, nil, false, []string{"itd-27 → itd-94", "not in this checkout's intent store"}}, {"a superseded record naming no successor", []rec{ {BucketSuperseded, "itd-27", "null"}, - }, false, []string{"itd-27", "names no successor"}}, - {"replaced by a decision", []rec{ + }, nil, false, []string{"itd-27", "names no successor"}}, + {"replaced by an accepted decision", []rec{ + {BucketSuperseded, "itd-27", "adr-37"}, + }, map[string]string{"0037-changelog-driven-releases.md": adrRecord("adr-37", "accepted")}, + true, []string{"itd-27 → adr-37 (accepted)"}}, + {"replaced by an accepted decision whose status line carries a comment", []rec{ + {BucketSuperseded, "itd-27", "adr-37"}, + }, map[string]string{"0037-x.md": adrRecord("adr-37", "accepted # proposed | accepted | superseded | deprecated")}, + true, []string{"itd-27 → adr-37 (accepted)"}}, + {"replaced by an accepted minted decision, named zero-padded", []rec{ + {BucketSuperseded, "itd-27", "adr-02609012206053814"}, + }, map[string]string{"2609012206053814-x.md": adrRecord("adr-2609012206053814", "accepted")}, + true, []string{"itd-27 → adr-2609012206053814 (accepted)"}}, + {"replaced by a proposed decision", []rec{ + {BucketSuperseded, "itd-27", "adr-37"}, + }, map[string]string{"0037-x.md": adrRecord("adr-37", "proposed")}, + false, []string{"itd-27 → adr-37", "adr-37 is proposed", "accepted"}}, + {"replaced by a decision carrying no status", []rec{ + {BucketSuperseded, "itd-27", "adr-37"}, + }, map[string]string{"0037-x.md": "---\nid: adr-37\n---\n# adr-37\n"}, + false, []string{"itd-27 → adr-37", "adr-37 carries no status"}}, + {"replaced by a decision this checkout does not hold", []rec{ + {BucketSuperseded, "itd-27", "adr-37"}, + }, nil, false, []string{"itd-27 → adr-37", "not in this checkout's decision store"}}, + {"replaced by a decision whose file claims another id", []rec{ {BucketSuperseded, "itd-27", "adr-37"}, - }, false, []string{"itd-27 → adr-37", "not an intent"}}, + }, map[string]string{"0037-x.md": adrRecord("adr-38", "accepted")}, + false, []string{"itd-27 → adr-37", "not in this checkout's decision store"}}, + {"replaced by a discipline", []rec{ + {BucketSuperseded, "itd-27", "itd-94"}, + {BucketDisciplines, "itd-94", ""}, + }, nil, true, []string{"itd-27 → itd-94 (disciplines)"}}, + {"reclassified as a discipline in place", []rec{ + {BucketDisciplines, "itd-27", ""}, + }, nil, true, []string{"names no unsettled blocker"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -69,11 +111,17 @@ func TestStartBlockedRowFollowsASupersededBlockerToItsReplacement(t *testing.T) for _, r := range tc.records { writeFile(t, root, filepath.Join(IntentsRelDir, r.bucket, r.id+"-"+strings.ReplaceAll(r.id, "itd-", "rec-")+".md"), blockerRecord(r.id, r.by)) } + for name, body := range tc.adrs { + writeFile(t, root, filepath.Join(filepath.FromSlash(decide.ADRsRelDir), name), body) + } corpus, err := Load(root) if err != nil { t.Fatal(err) } - row := startBlockedRow(corpus, "itd-10", "---\nid: itd-10\nblocked_by: [itd-27]\n---\n") + row, err := startBlockedRow(root, corpus, "itd-10", "---\nid: itd-10\nblocked_by: [itd-27]\n---\n") + if err != nil { + t.Fatal(err) + } if row.OK != tc.ok { t.Fatalf("want OK=%v, got %+v", tc.ok, row) } @@ -99,7 +147,10 @@ func TestStartBlockedRowKeepsAPlainUnshippedBlocker(t *testing.T) { if err != nil { t.Fatal(err) } - row := startBlockedRow(corpus, "itd-10", "---\nid: itd-10\nblocked_by: [itd-27, itd-99]\n---\n") + row, err := startBlockedRow(root, corpus, "itd-10", "---\nid: itd-10\nblocked_by: [itd-27, itd-99]\n---\n") + if err != nil { + t.Fatal(err) + } if row.OK { t.Fatalf("an unshipped blocker blocks: %+v", row) } diff --git a/internal/core/statusblock/statusblock_test.go b/internal/core/statusblock/statusblock_test.go index ffcf22f11..0708bdd1c 100644 --- a/internal/core/statusblock/statusblock_test.go +++ b/internal/core/statusblock/statusblock_test.go @@ -353,6 +353,65 @@ func TestTheHeadIsThePicksChoice(t *testing.T) { } } +// TestTheHeadTakesAnIntentWhoseBlockerASettledRecordReplaced is rulings CF1 +// and CF2 of 2026-09-30 on the board: the "next up" reads the build's own +// blocked check (intent.StartChecksIn), so an intent whose blocker was +// superseded by an accepted decision, or reclassified as a discipline, heads +// the board, and one whose blocker a proposed decision replaced does not. +func TestTheHeadTakesAnIntentWhoseBlockerASettledRecordReplaced(t *testing.T) { + const superseded = "---\nid: itd-27\nslug: s\nkind: standalone\nsuperseded_by: adr-37\nkind_at_supersession: standalone\n---\n# The replaced one\n" + cases := []struct { + name string + files map[string]string + heads bool + }{ + {"superseded by an accepted decision", map[string]string{ + ".abcd/development/intents/superseded/itd-27-s.md": superseded, + ".abcd/development/decisions/adrs/0037-d.md": "---\nid: adr-37\nstatus: accepted\n---\n# d\n", + }, true}, + {"reclassified as a discipline", map[string]string{ + ".abcd/development/intents/disciplines/itd-27-s.md": "---\nid: itd-27\nslug: s\nkind: discipline\n---\n# A rule\n", + }, true}, + {"superseded by a proposed decision", map[string]string{ + ".abcd/development/intents/superseded/itd-27-s.md": superseded, + ".abcd/development/decisions/adrs/0037-d.md": "---\nid: adr-37\nstatus: proposed\n---\n# d\n", + }, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + w := func(rel, body string) { + t.Helper() + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + w(".abcd/development/intents/planned/itd-4-blocked.md", readyIntent("itd-4", "The blocked one", "spc-14", "blocked_by: [itd-27]\n")) + w(".abcd/development/specs/open/spc-14-blocked.md", scoredSpec("spc-14", "itd-4")) + for rel, body := range tc.files { + w(rel, body) + } + b, err := Read(root, nil) + if err != nil { + t.Fatal(err) + } + if got := ids(b.Next); !reflect.DeepEqual(got, []string{"itd-4"}) { + t.Fatalf("Next = %v, want [itd-4]", got) + } + switch { + case tc.heads && (len(b.Now) != 1 || b.Now[0].ID != "itd-4" || !b.Now[0].NextUp): + t.Errorf("Now = %+v, want itd-4 marked next up: its blocker is settled", b.Now) + case !tc.heads && len(b.Now) != 0: + t.Errorf("Now = %+v, want no head: a proposed decision does not settle the blocker", b.Now) + } + }) + } +} + // TestTheHeadPassesOverWhatTheBuildRefusesFromTheRecord is the head under the // build's record-only pre-start checks (iss-2609291803334904): a READY intent // with an open question, an unanswered claim section, an unshipped blocker, or diff --git a/internal/surface/cli/build.go b/internal/surface/cli/build.go index 166c8e108..7379f8570 100644 --- a/internal/surface/cli/build.go +++ b/internal/surface/cli/build.go @@ -92,7 +92,7 @@ func newBuildCommand(asJSON *bool) *cobra.Command { Long: "Start the implement loop for one intent, or resume the run already in progress for it.\n" + "A new run's checks run first, and every one must pass:\n" + "the intent is READY (planned, criteria written, its spec linked and written), asks no\n" + - "open question, has no unanswered claim section, is not held, names no unshipped intent\n" + + "open question, has no unanswered claim section, is not held, names no unsettled blocker\n" + "in `blocked_by`, its spec leaves a step to build, and no peer holds it (no sibling\n" + "worktree or local branch holds it in another bucket, and no session holds a live claim\n" + "on it; a peer or claim that cannot be read counts as holding it). A refusal names the\n" + @@ -182,7 +182,7 @@ func newBuildNextCommand(asJSON *bool) *cobra.Command { Use: "next [--session <id>] [--pace <work-minutes>/<pause-minutes>] [--sub-agents <n>] [--max <n>] [--until-empty]", Long: "Pick the readiest planned intent, write down why, and start its run.\n\n" + "The candidates are the planned intents that pass every check `abcd build <itd-N>` runs\n" + - "(READY, no open question, no unanswered claim section, not held, no unshipped intent in\n" + + "(READY, no open question, no unanswered claim section, not held, no unsettled blocker in\n" + "`blocked_by`, a step left to build, no peer holding it), less one this checkout already has\n" + "a run in progress for. Each is scored from its record, three parts at equal weight, each 0\n" + "to 100: criteria clarity (the share of its acceptance criteria in Given-When-Then form), a\n" + From d5f7b43698e14929a57ecb3406d64ec484eafa61 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:27:05 +0100 Subject: [PATCH 51/69] =?UTF-8?q?chore:=20resolve=20iss-2609300751191426?= =?UTF-8?q?=20=E2=80=94=20rulings=20CF1=20and=20CF2=20are=20built?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blocked pre-start check settles a blocker replaced by an accepted ADR or reclassified as a discipline (fixed in b6f27a400). Resolves: iss-2609300751191426 Assisted-by: Claude:claude-opus-5-5 --- ...cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609300751191426-rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md (73%) diff --git a/.abcd/work/issues/open/iss-2609300751191426-rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md b/.abcd/work/issues/resolved/iss-2609300751191426-rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md similarity index 73% rename from .abcd/work/issues/open/iss-2609300751191426-rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md rename to .abcd/work/issues/resolved/iss-2609300751191426-rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md index b0cf1727b..5a98ff6d9 100644 --- a/.abcd/work/issues/open/iss-2609300751191426-rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md +++ b/.abcd/work/issues/resolved/iss-2609300751191426-rulings-cf1-and-cf2-of-2026-09-30-are-not-built-the-blocked.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/intent/startcheck.go" remedy: "In internal/core/intent/startcheck.go, end a supersession chain at an ADR successor by reading that ADR's status: status accepted settles the edge (the row passes and names the chain, the ADR marked accepted), while any other status, or an ADR this checkout does not hold, still refuses and names it; and let startBlockedRow treat a final record in disciplines/ as settled beside shipped/. Update the function comments, brief 04-surfaces/34-build.md and commands/build.md in the same change. Prove it by turning the 'adr successor' case of TestStartBlockedRowFollowsASupersededBlockerToItsReplacement into a pass for an accepted ADR and a refusal for a proposed one, plus a discipline-successor case (grounds: the rulings themselves; the fix depends on no outside practice)." +resolution: "Built rulings CF1 and CF2: the blocked pre-start check settles a supersession chain ending at an accepted ADR (read through the record-id resolver, both id vintages) and a final record in disciplines/; a decision in any other status or absent still refuses naming it. The board's next up follows through the shared StartChecksIn seam." +impact: fix +resolved_by: + commit: "b6f27a400" --- Rulings CF1 and CF2 of 2026-09-30 are not built: the blocked pre-start check still refuses a blocker whose supersession chain ends at an accepted ADR, and a blocker reclassified as a discipline. The person ruled, through the run's interviewer on 2026-09-30: 'CF1 blocker replaced by an accepted ADR: COUNTS AS SETTLED (the waiting intent may start)' and 'CF2 blocker reclassified as a discipline: COUNTS AS SETTLED (the waiting intent may start)'. At base 85d0bb8eb, followBlocker in internal/core/intent/startcheck.go stops an ADR successor with the problem 'adr-N is not an intent: a decision replaced the blocker, and nothing on the record says that settles the edge', and startBlockedRow counts only a final record in shipped/ as settled, so a chain ending in disciplines/ blocks its dependants for ever. Lane supersededBlocker asked both questions rather than building them; no live edge hits either shape today (itd-72 is superseded by adr-37 and blocks nothing), so nothing is refused wrongly yet. + +## Grounds + +- pursued: we expect an intent blocked by a record superseded by an accepted ADR, or by one in disciplines/, to pass the blocked check and head the board, and one superseded by a proposed ADR to stay refused; shown wrong if TestStartBlockedRowFollowsASupersededBlockerToItsReplacement or TestTheHeadTakesAnIntentWhoseBlockerASettledRecordReplaced fails From 92f0845001936583143cbd8c46d3e7a7c87d2489 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:32:03 +0100 Subject: [PATCH 52/69] fix(ahoy): say a skipped provider route at connect, credential and the bare board The provider configuration read's diagnostics (a repository's route to a provider that holds a key, skipped under ruling CD2; a route to an unconfigured provider; a role outside the roster) reached the person only through `ahoy --providers` and bare `ahoy credential`. `ahoy connect` and `ahoy credential <name>` read the configuration and dropped them, and the bare `ahoy` board said nothing. - oracle.Connect carries them on ConnectResult.Diagnostics, which the JSON omits, and oracle.CredentialService returns them beside the service. - One cli printer, printConfigDiagnostics, says them on stderr for `ahoy connect` (text and JSON), `ahoy credential` bare and by name. - detectProviderAdapter raises the optional gap oracle_api.route_skipped, one skipped route per line of its detail, which the bare board renders as a `provider: route skipped` line each. Refs: iss-2609300805090515 Assisted-by: Claude:claude-opus-5-5 --- .../brief/05-internals/03-configuration.md | 5 +- commands/ahoy.md | 11 ++- internal/core/ahoy/provider_adapter.go | 41 ++++++++-- internal/core/ahoy/provider_adapter_test.go | 45 +++++++++++ internal/core/oracle/connect.go | 17 ++-- internal/core/oracle/connect_test.go | 26 +++++++ internal/surface/cli/ahoy_connect.go | 14 ++++ internal/surface/cli/ahoy_connect_test.go | 77 +++++++++++++++++++ internal/surface/cli/ahoy_credential.go | 15 ++-- internal/surface/cli/ahoy_credential_test.go | 22 ++++++ internal/surface/cli/cli.go | 4 + 11 files changed, 255 insertions(+), 22 deletions(-) diff --git a/.abcd/development/brief/05-internals/03-configuration.md b/.abcd/development/brief/05-internals/03-configuration.md index 11d02113b..ff795d6e0 100644 --- a/.abcd/development/brief/05-internals/03-configuration.md +++ b/.abcd/development/brief/05-internals/03-configuration.md @@ -131,7 +131,10 @@ rather than skipped: technical facilitator's ruling CD2 of 2026-09-29). The rest of the configuration loads, so every other route and every command that reads it keeps working, and the machine's own route to that name, if it has one, - applies in its place. A route the denylist matches is refused whichever + applies in its place. Every front door that reads the configuration says + each skipped route: `abcd ahoy connect` and `abcd ahoy credential` on + stderr, the `abcd ahoy --providers` board among its lines, and the bare + `abcd ahoy` board as the optional gap `oracle_api.route_skipped`. A route the denylist matches is refused whichever provider it names. A provider holds a key when its block names `key`, judged from the block and never by reading the credential store. A repository's route to a provider diff --git a/commands/ahoy.md b/commands/ahoy.md index 97542f4e1..97f8d49a5 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -430,6 +430,10 @@ configured provider changes no step until provider dispatch lands. The bare board names the same adapter as an optional gap (`oracle_api.none_configured`) while none is configured, and a configuration the adapter refuses as `oracle_api.config_refused`, naming the file and the key. +A route the configuration read skips (a repository's route to a provider that +holds a key, a route to a provider this machine has not configured, or a role +outside the roster) is the optional gap `oracle_api.route_skipped`, its +`detail` one line per skipped route; relay each line. Declining is not running `connect`, and it changes nothing. The setup is `abcd ahoy connect <provider> --base-url <url> --model <model> @@ -457,7 +461,9 @@ would be echoed. **Never ask the person for the key and never pass it yourself**: it would enter this conversation. Give them the command to run in their own shell, with the key piped in from a file or a variable they hold, and relay the result — `verified` (the provider, the model asked for, the model -it reported and the credential's name), each `wrote` path, and `dispatch`. +it reported and the credential's name), each `wrote` path, and `dispatch`. A +route the configuration read skips is named on stderr, in the text and the JSON +form alike, and the setup stands: relay that line too. ## `credential` — the credential store's walkthrough @@ -472,7 +478,8 @@ adapter reads (`hosting.cloudflare` for the site setup, each configured provider's key) with its `state` (`set`, `not set`, or a refusal) and `home`; never a value. A route the configuration read skips (a repository's route to a provider that holds a key) is named on stderr and the listing goes on: relay -that line too. With a name it explains that credential and writes nothing: +that line too, as with a name that is a provider's credential, whose read of +the configuration names it the same way. With a name it explains that credential and writes nothing: relay `unlocks`, `without_it`, then `homes_prose` verbatim (it recommends the platform keychain in the prose; never present one home as the marked option), then the `homes` and the `setup` command for each. diff --git a/internal/core/ahoy/provider_adapter.go b/internal/core/ahoy/provider_adapter.go index db3490fe2..84d0aefe3 100644 --- a/internal/core/ahoy/provider_adapter.go +++ b/internal/core/ahoy/provider_adapter.go @@ -13,6 +13,8 @@ package ahoy // every delegated step runs on the host. import ( + "strings" + "github.com/intentdriven/abcd/internal/core/layered" "github.com/intentdriven/abcd/internal/core/oracle" "github.com/intentdriven/abcd/internal/fsutil" @@ -26,6 +28,10 @@ const ( // ProviderAdapterRefusedGapID names a provider configuration the adapter // refuses, so it is never silently unused. ProviderAdapterRefusedGapID = "oracle_api.config_refused" + // ProviderAdapterRouteSkippedGapID names each route the configuration + // read skipped, one diagnostic per line of its Detail, so a skip is never + // silent at the bare board. + ProviderAdapterRouteSkippedGapID = "oracle_api.route_skipped" ) func detectProviderAdapter(cwd string) []Gap { @@ -40,15 +46,38 @@ func detectProviderAdapter(cwd string) []Gap { Required: false, Resolvable: false, }} } - if len(cfg.Providers()) > 0 { + var gaps []Gap + if len(cfg.Providers()) == 0 { + gaps = append(gaps, Gap{ + ID: ProviderAdapterGapID, Category: UserState, Scope: "machine", + Title: "no OpenAI-compatible provider configured (optional)", + Detail: oracle.AdapterExplanation, + FixHint: "`abcd ahoy --providers` walks through the setup and where the key can live; `abcd ahoy connect` sets one up. " + + "Declining changes nothing: every delegated step runs on the host, the only route.", + Required: false, Resolvable: false, + }) + } + return append(gaps, skippedRoutes(cfg.Diagnostics)...) +} + +// skippedRoutes is the gap naming each route the configuration read skipped +// (a role outside the roster, a route to a provider this machine has not +// configured, a repository's route to a provider that holds a key), one +// diagnostic per line of its Detail. It is advisory: the rest of the +// configuration applies, so nothing is required of the person. +func skippedRoutes(diagnostics []string) []Gap { + if len(diagnostics) == 0 { return nil } + lines := make([]string, len(diagnostics)) + for i, d := range diagnostics { + lines[i] = termsafe.Sanitize(fsutil.RedactHome(d)) + } return []Gap{{ - ID: ProviderAdapterGapID, Category: UserState, Scope: "machine", - Title: "no OpenAI-compatible provider configured (optional)", - Detail: oracle.AdapterExplanation, - FixHint: "`abcd ahoy --providers` walks through the setup and where the key can live; `abcd ahoy connect` sets one up. " + - "Declining changes nothing: every delegated step runs on the host, the only route.", + ID: ProviderAdapterRouteSkippedGapID, Category: UserState, Scope: "machine", + Title: "a provider route is skipped", + Detail: strings.Join(lines, "\n"), + FixHint: "Each line names the route, why it is skipped and where to change it; `abcd ahoy --providers` shows the routes in force.", Required: false, Resolvable: false, }} } diff --git a/internal/core/ahoy/provider_adapter_test.go b/internal/core/ahoy/provider_adapter_test.go index 27c59f9b3..52b404da2 100644 --- a/internal/core/ahoy/provider_adapter_test.go +++ b/internal/core/ahoy/provider_adapter_test.go @@ -94,3 +94,48 @@ func TestARefusedProviderConfigurationIsNamed(t *testing.T) { t.Fatalf("the gap carries the home path: %s", g.Detail) } } + +// TestASkippedProviderRouteIsNamed: a route the configuration read skips (a +// repository's route to a provider that holds a key, ruling CD2 of +// 2026-09-29) is an optional gap naming the route, never silence at the bare +// board, and it costs nothing else: the configuration still loads. +func TestASkippedProviderRouteIsNamed(t *testing.T) { + home, _ := setupHermetic(t) + repo := installedRepo(t) + if err := os.MkdirAll(filepath.Join(home, ".abcd"), 0o700); err != nil { + t.Fatal(err) + } + cfg := `{"oracle":{"api":{"openrouter":{"base_url":"https://openrouter.ai/api/v1","key":"openrouter","models":["typesafe/jev-1.13"]}}}}` + if err := os.WriteFile(filepath.Join(home, ".abcd", "config.json"), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repo, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + route := `{"oracle":{"roles":{"scribe":"openrouter/typesafe/jev-1.13"}}}` + if err := os.WriteFile(filepath.Join(repo, ".abcd", "config.json"), []byte(route), 0o644); err != nil { + t.Fatal(err) + } + det, err := Detect(repo) + if err != nil { + t.Fatal(err) + } + if _, ok := providerGap(det.Gaps, ProviderAdapterRefusedGapID); ok { + t.Fatal("one skipped repository route refused the whole configuration") + } + g, ok := providerGap(det.Gaps, ProviderAdapterRouteSkippedGapID) + if !ok { + t.Fatalf("no %s gap in %v", ProviderAdapterRouteSkippedGapID, gapIDs(det.Gaps)) + } + if g.Required || g.Resolvable || g.Scope != "machine" { + t.Fatalf("gap = %+v; want optional, not resolvable by install, machine-scoped", g) + } + for _, want := range []string{"oracle.roles.scribe", "openrouter/typesafe/jev-1.13", "holds a key", "skipped"} { + if !strings.Contains(g.Detail, want) { + t.Errorf("the gap does not name %q:\n%s", want, g.Detail) + } + } + if strings.Contains(g.Detail, home) { + t.Fatalf("the gap carries the home path: %s", g.Detail) + } +} diff --git a/internal/core/oracle/connect.go b/internal/core/oracle/connect.go index f1963bbeb..0ae01e394 100644 --- a/internal/core/oracle/connect.go +++ b/internal/core/oracle/connect.go @@ -78,6 +78,10 @@ type ConnectResult struct { Verified CallRecord `json:"verified"` // Wrote names each file written, in the tilde form. Wrote []string `json:"wrote"` + // Diagnostics are the configuration read's non-fatal reports (APIConfig's), + // for the front door to print on stderr; the JSON form omits them, so they + // are said once and never mixed into what a machine reader parses. + Diagnostics []string `json:"-"` } // verifyBrief is the verification call's brief: one short exchange, judged @@ -112,7 +116,7 @@ func Connect(ctx context.Context, req ConnectRequest) (ConnectResult, error) { opts = append(opts, openaiapi.WithTimeout(req.Timeout)) } res := ConnectResult{Provider: req.Provider, BaseURL: req.BaseURL, Models: append([]string(nil), req.Models...), - KeyHome: req.Home} + KeyHome: req.Home, Diagnostics: append([]string(nil), cfg.Diagnostics...)} svc := providerService(Provider{Name: req.Provider, BaseURL: req.BaseURL, Key: req.KeyName, Models: req.Models}, cfg.denylist, &res.Verified, opts...) block := map[string]any{"base_url": req.BaseURL, "models": req.Models} @@ -352,16 +356,17 @@ func providerService(p Provider, denylist []DenyEntry, rec *CallRecord, opts ... // CredentialService is the walkthrough's service for the credential name, when // a configured provider names it as its key: the walkthrough then verifies a // key with that provider's own call. A name no provider names is not the -// adapter's. -func CredentialService(roots layered.Roots, name string) (credential.Service, bool, error) { +// adapter's. The configuration read's diagnostics come back beside it, for the +// front door to print on stderr, whether or not a provider names the name. +func CredentialService(roots layered.Roots, name string) (credential.Service, bool, []string, error) { cfg, err := LoadAPI(roots) if err != nil { - return credential.Service{}, false, err + return credential.Service{}, false, nil, err } for _, p := range cfg.Providers() { if p.Key == name && len(p.Models) > 0 { - return providerService(p, cfg.denylist, nil), true, nil + return providerService(p, cfg.denylist, nil), true, cfg.Diagnostics, nil } } - return credential.Service{}, false, nil + return credential.Service{}, false, cfg.Diagnostics, nil } diff --git a/internal/core/oracle/connect_test.go b/internal/core/oracle/connect_test.go index 783dd1cf1..627004276 100644 --- a/internal/core/oracle/connect_test.go +++ b/internal/core/oracle/connect_test.go @@ -272,6 +272,32 @@ func TestConnectToALocalServerNeedsNoKey(t *testing.T) { } } +// TestConnectCarriesTheConfigurationReadsDiagnostics: the setup reads the +// configuration in force before it writes, and a route that read skipped (a +// repository's route to a provider that holds a key, ruling CD2 of 2026-09-29) +// comes back on the result for the front door to say, never dropped. The JSON +// form omits it, so a front door says it once, on stderr. +func TestConnectCarriesTheConfigurationReadsDiagnostics(t *testing.T) { + p := newProvFake(t, 200, chat("local-model", "ok")) + f := newFx(t) + f.machineConfig(`{"oracle":{"api":{` + openrouterBlock + `}}}`) + f.repoConfig(`{"oracle":{"roles":{"scribe":"openrouter/typesafe/jev-1.13"}}}`) + req := connectReq(f, p.base()) + req.Provider, req.Home, req.Key = "desk", KeyHomeNone, "" + res, err := Connect(context.Background(), req) + if err != nil { + t.Fatalf("Connect: %v", err) + } + if len(res.Diagnostics) != 1 || !strings.Contains(res.Diagnostics[0], "oracle.roles.scribe") || + !strings.Contains(res.Diagnostics[0], "holds a key") { + t.Fatalf("diagnostics = %q; want the skipped repository route named", res.Diagnostics) + } + enc, _ := json.Marshal(res) + if strings.Contains(string(enc), "holds a key") { + t.Fatalf("the JSON result carries the diagnostic, which the front door prints on stderr:\n%s", enc) + } +} + // TestConcurrentConnectsKeepEveryKeyAndBlock: two setups that overlap must // not lose each other's key or provider block while each reports it wrote // them. Every connect's key resolves and every block reads back. diff --git a/internal/surface/cli/ahoy_connect.go b/internal/surface/cli/ahoy_connect.go index d3fb758f2..fc99929ef 100644 --- a/internal/surface/cli/ahoy_connect.go +++ b/internal/surface/cli/ahoy_connect.go @@ -123,6 +123,17 @@ func runAhoyProviders(cmd *cobra.Command, cwd string, asJSON bool) error { }) } +// printConfigDiagnostics says the provider configuration read's non-fatal +// reports (oracle.APIConfig.Diagnostics: a route skipped, and why) on w, one +// line each. It is the one printer every front door that reads the +// configuration and is not the board uses, so a skipped route is said the +// same way wherever it is met. +func printConfigDiagnostics(w io.Writer, diagnostics []string) { + for _, d := range diagnostics { + fmt.Fprintf(w, "abcd %s\n", termsafe.Sanitize(fsutil.RedactHome(d))) + } +} + // keyState says whether a named credential resolves through the store, and // from which home: set, not set, refused (the store is unsafe), or none for a // keyless provider. Never the value. @@ -176,6 +187,9 @@ func newAhoyConnectCommand(asJSON *bool) *cobra.Command { msg := openaiapi.Scrub(err.Error(), req.Key) return &exitError{Code: 2, Msg: "abcd ahoy connect: " + termsafe.Sanitize(fsutil.RedactHome(msg))} } + // A route the configuration read skipped (ruling CD2) is said on + // stderr, in the text and the JSON form alike, and the setup stands. + printConfigDiagnostics(cmd.ErrOrStderr(), res.Diagnostics) return render(cmd.OutOrStdout(), *asJSON, withMember{v: res, key: "dispatch", val: dispatchPending}, func(w io.Writer) { line := func(s string) { fmt.Fprintf(w, " %s\n", termsafe.Sanitize(s)) } fmt.Fprintf(w, "abcd ahoy connect — %s verified and configured\n", termsafe.Sanitize(res.Provider)) diff --git a/internal/surface/cli/ahoy_connect_test.go b/internal/surface/cli/ahoy_connect_test.go index 293df408c..c23779448 100644 --- a/internal/surface/cli/ahoy_connect_test.go +++ b/internal/surface/cli/ahoy_connect_test.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "encoding/json" "io" "net/http" @@ -223,3 +224,79 @@ func TestBareAhoyNamesTheProviderAdapter(t *testing.T) { t.Fatalf("bare ahoy does not name the provider adapter:\n%s", out) } } + +// repoRouteToKeyedProvider sets up a machine provider that holds a key and a +// repository whose configuration routes a role to it, the route ruling CD2 of +// 2026-09-29 skips, and changes into the repository. +func repoRouteToKeyedProvider(t *testing.T) string { + t.Helper() + providerNamingKey(t, "https://openrouter.ai/api/v1") + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repo, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".abcd", "config.json"), + []byte(`{"oracle":{"roles":{"scribe":"openrouter/typesafe/jev-1.13"}}}`), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + return repo +} + +// TestAhoyConnectSaysASkippedRoute: the setup reads the configuration in +// force, and a route that read skipped is said once on stderr, in the text +// form and the JSON form alike, and never in what a machine reader parses. +func TestAhoyConnectSaysASkippedRoute(t *testing.T) { + for _, asJSON := range []bool{false, true} { + t.Run(map[bool]string{false: "text", true: "json"}[asJSON], func(t *testing.T) { + hermeticEnv(t) + repoRouteToKeyedProvider(t) + base, calls, _ := fakeProvider(t, 200, completionReply("qwen/qwen3-8b")) + args := []string{"ahoy", "connect", "desk", "--base-url", base, "--model", "qwen/qwen3-8b", "--home", "none"} + if asJSON { + args = append(args, "--json") + } + root := NewRootCommand() + root.SetArgs(args) + var so, se bytes.Buffer + root.SetOut(&so) + root.SetErr(&se) + if err := root.Execute(); err != nil || calls.Load() != 1 { + t.Fatalf("ahoy connect (%d call(s)): %v\n%s%s", calls.Load(), err, so.String(), se.String()) + } + if n := strings.Count(se.String(), "holds a key"); n != 1 { + t.Fatalf("stderr carries %d keyed-route warning(s), want one:\n%s", n, se.String()) + } + for _, want := range []string{"abcd oracle adapter: ", "oracle.roles.scribe", "openrouter/typesafe/jev-1.13", "skipped"} { + if !strings.Contains(se.String(), want) { + t.Errorf("the warning does not name %q:\n%s", want, se.String()) + } + } + if strings.Contains(so.String(), "holds a key") { + t.Errorf("stdout carries the warning:\n%s", so.String()) + } + if asJSON && !json.Valid(so.Bytes()) { + t.Errorf("stdout is not one JSON document:\n%s", so.String()) + } + }) + } +} + +// TestBareAhoyNamesASkippedRoute: the bare board names a route the +// configuration read skipped, where it names the provider adapter. +func TestBareAhoyNamesASkippedRoute(t *testing.T) { + hermeticEnv(t) + repoRouteToKeyedProvider(t) + out, err := runCLIErr(t, "ahoy") + if err != nil { + t.Fatalf("ahoy: %v\n%s", err, out) + } + for _, want := range []string{"provider: route skipped — oracle adapter: ", "oracle.roles.scribe", "holds a key"} { + if !strings.Contains(string(out), want) { + t.Errorf("bare ahoy does not say %q:\n%s", want, out) + } + } +} diff --git a/internal/surface/cli/ahoy_credential.go b/internal/surface/cli/ahoy_credential.go index e4a64e43f..002c10b48 100644 --- a/internal/surface/cli/ahoy_credential.go +++ b/internal/surface/cli/ahoy_credential.go @@ -38,12 +38,15 @@ func pointerFlags(cmd *cobra.Command, p *credential.Pointer) { // credentialService finds the walkthrough's service for name among the // adapters that read a credential: the site setup's hosting providers, then -// the configured model providers. -func credentialService(roots layered.Roots, name string) (credential.Service, bool, error) { +// the configured model providers. Reading the provider configuration says its +// diagnostics on stderr (a route skipped under ruling CD2). +func credentialService(stderr io.Writer, roots layered.Roots, name string) (credential.Service, bool, error) { if svc, ok := site.CredentialServiceFor(name); ok { return svc, true, nil } - return oracle.CredentialService(roots, name) + svc, ok, diagnostics, err := oracle.CredentialService(roots, name) + printConfigDiagnostics(stderr, diagnostics) + return svc, ok, err } // credentialView is one credential as a surface shows it: presence and home, @@ -97,7 +100,7 @@ func newAhoyCredentialCommand(asJSON *bool) *cobra.Command { if !credential.ValidName(name) { return fail(errors.New("the name is not a plain credential name"), "") } - svc, ok, err := credentialService(roots, name) + svc, ok, err := credentialService(cmd.ErrOrStderr(), roots, name) if err != nil { return fail(err, "") } @@ -160,9 +163,7 @@ func runCredentialList(cmd *cobra.Command, roots layered.Roots, asJSON bool) err } // A route the read skipped (a repository's route to a provider that holds // a key, ruling CD2) is said on stderr, and the listing goes on. - for _, d := range cfg.Diagnostics { - fmt.Fprintf(cmd.ErrOrStderr(), "abcd %s\n", termsafe.Sanitize(fsutil.RedactHome(d))) - } + printConfigDiagnostics(cmd.ErrOrStderr(), cfg.Diagnostics) for _, p := range cfg.Providers() { if p.Key != "" { names[p.Key] = true diff --git a/internal/surface/cli/ahoy_credential_test.go b/internal/surface/cli/ahoy_credential_test.go index f7d5cf13c..1fcf416bc 100644 --- a/internal/surface/cli/ahoy_credential_test.go +++ b/internal/surface/cli/ahoy_credential_test.go @@ -201,3 +201,25 @@ func TestARepositoryRouteToAKeyedProviderIsSkippedWithAWarning(t *testing.T) { } } } + +// TestAhoyCredentialByNameSaysASkippedRoute: naming a provider's credential +// reads the provider configuration to find the provider that verifies it, and +// a route that read skipped (ruling CD2) is said on stderr there too. +func TestAhoyCredentialByNameSaysASkippedRoute(t *testing.T) { + hermeticEnv(t) + repoRouteToKeyedProvider(t) + root := NewRootCommand() + root.SetArgs([]string{"ahoy", "credential", "openrouter"}) + var so, se bytes.Buffer + root.SetOut(&so) + root.SetErr(&se) + if err := root.Execute(); err != nil { + t.Fatalf("ahoy credential openrouter: %v\n%s%s", err, so.String(), se.String()) + } + if n := strings.Count(se.String(), "holds a key"); n != 1 { + t.Fatalf("stderr carries %d keyed-route warning(s), want one:\n%s", n, se.String()) + } + if !strings.Contains(se.String(), "oracle.roles.scribe") || strings.Contains(so.String(), "holds a key") { + t.Fatalf("stdout:\n%s\nstderr:\n%s", so.String(), se.String()) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 4d921ff2b..4c925d600 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3391,6 +3391,10 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { fmt.Fprintf(w, " provider: none configured (optional); every delegated step runs on the host — `abcd ahoy --providers` explains the adapter\n") case ahoy.ProviderAdapterRefusedGapID: fmt.Fprintf(w, " provider: configuration refused — %s\n", termsafe.Sanitize(g.Detail)) + case ahoy.ProviderAdapterRouteSkippedGapID: + for _, d := range strings.Split(g.Detail, "\n") { + fmt.Fprintf(w, " provider: route skipped — %s\n", termsafe.Sanitize(d)) + } } } if res.FolderKind != ahoy.UnmanagedFolder { From 5e6f1ff262495ee1a10aca03b86d747037e78041 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:32:23 +0100 Subject: [PATCH 53/69] =?UTF-8?q?chore:=20resolve=20iss-2609300805090515?= =?UTF-8?q?=20=E2=80=94=20skipped=20provider=20routes=20are=20said=20at=20?= =?UTF-8?q?every=20door?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609300805090515 Assisted-by: Claude:claude-opus-5-5 --- ...ovider-configuration-s-diagnostics-oracle-apiconfig.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609300805090515-the-provider-configuration-s-diagnostics-oracle-apiconfig.md (72%) diff --git a/.abcd/work/issues/open/iss-2609300805090515-the-provider-configuration-s-diagnostics-oracle-apiconfig.md b/.abcd/work/issues/resolved/iss-2609300805090515-the-provider-configuration-s-diagnostics-oracle-apiconfig.md similarity index 72% rename from .abcd/work/issues/open/iss-2609300805090515-the-provider-configuration-s-diagnostics-oracle-apiconfig.md rename to .abcd/work/issues/resolved/iss-2609300805090515-the-provider-configuration-s-diagnostics-oracle-apiconfig.md index 634faee0a..f90a2beef 100644 --- a/.abcd/work/issues/open/iss-2609300805090515-the-provider-configuration-s-diagnostics-oracle-apiconfig.md +++ b/.abcd/work/issues/resolved/iss-2609300805090515-the-provider-configuration-s-diagnostics-oracle-apiconfig.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/oracle/connect.go" remedy: "Carry the configuration read's diagnostics out of oracle.Connect on its result (a field the JSON omits, or a named diagnostics member) and print each on stderr in the ahoy connect front door, the way ahoy credential does; give detectProviderAdapter a non-required gap naming each skipped route. Grounds: the APIConfig doc comment already says the diagnostics are for a front door to print on stderr, and ruling CD2 asks for the skip to be said; a test per door with a repository route to a keyed provider shows it." +resolution: "ahoy connect and ahoy credential <name> now say each skipped route on stderr through one shared printer, and the bare ahoy board carries the optional gap oracle_api.route_skipped naming each" +impact: fix +resolved_by: + commit: "92f084500" --- The provider configuration's diagnostics (oracle.APIConfig.Diagnostics: a role outside the roster, a route to an unconfigured provider, and a repository's route to a keyed provider skipped under ruling CD2) reach the person only through 'abcd ahoy --providers' (its board) and 'abcd ahoy credential' bare (stderr). 'abcd ahoy connect' (oracle.Connect loads the configuration and drops them) and the bare 'abcd ahoy' provider-adapter gap (detectProviderAdapter) say nothing, so a skipped route is silent there. + +## Grounds + +- pursued: a repository route to a keyed provider is named on stderr by ahoy connect (text and JSON) and ahoy credential <name>, and as a route_skipped gap on the bare board; a front door that reads the configuration and stays silent would show it wrong From 52327c019fb232a3271f074c025b7bcdb82be976 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:36:58 +0100 Subject: [PATCH 54/69] docs(decisions): record ruling DQ2b, reversing itd-180's warm-work-only recurrence rule The person ruled on 2026-09-30 that a reading finding's likely repeats are matched mechanically: stored as a link on the finding, shown at storing time, and checked again at the promote step. That reverses itd-180's ruling that spotting a recurrence is the researcher's warm work and never a mechanical join. - adr-2609300821558671 records the ruling verbatim, with a typed `reverses` link to itd-180 (frontmatter and the Typed links line). - itd-180 states the two halves of the recurrence link in the present tense, citing the ADR: the stored duplicates:/refines: proposal, and the researcher's `recurs` citation as its confirmed form. No disposition state means "already covered". - One DECISIONS.md line, appended. Refs: iss-2609281911024185 Assisted-by: Claude:claude-opus-5-5 --- ...s-matched-against-the-record-when-it-is.md | 105 ++++++++++++++++++ .abcd/development/decisions/adrs/README.md | 1 + ...ings-land-as-reading-records-and-the-re.md | 29 +++-- .abcd/work/DECISIONS.md | 1 + 4 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 .abcd/development/decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md diff --git a/.abcd/development/decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md b/.abcd/development/decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md new file mode 100644 index 000000000..bf04b3974 --- /dev/null +++ b/.abcd/development/decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md @@ -0,0 +1,105 @@ +--- +id: adr-2609300821558671 +slug: a-reading-finding-is-matched-against-the-record-when-it-is +status: accepted +date: 2026-09-30 +supersedes: null +superseded_by: null +reverses: [itd-180] +related_intents: [itd-180, itd-2609212137116617] +related_rfcs: [] +related_adrs: [] +--- + +# ADR-2609300821558671: A reading finding is matched against the record when it is stored, reversing itd-180's warm-work-only rule + +Typed links: `reverses` [itd-180](../../intents/shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) +(its ruling that recurrence matching is warm work: run-scoped identifiers join +nothing mechanically); `builds_on` +[itd-2609212137116617](../../intents/shipped/itd-2609212137116617-a-new-capture-or-draft-is-matched-against-the-record-before.md) +(the filing-time match this extends to the reading family). + +## Context + +The filing-time match (itd-2609212137116617) compares a new issue or intent +with the record before it is written and links a likely double as +`duplicates:` or `refines:`. Ruling DQ2 of 2026-09-29 asked for the check on +every route that files a record, the reading ingest among them +(iss-2609281911024185). The inbox promote and the consistency pass took the +match without difficulty, because both file issues through `capture`. + +The reading ingest does not. It writes reading records (`rdi-N`), a separate +family with a closed field list and no link key, and shipped itd-180 ruled +that spotting a recurrence is the researcher's warm work: run-scoped +identifiers join nothing mechanically, the researcher recognises a recurrence +against the ledger, and the disposition's `recurs` citation is the recorded +form of that recognition. A link the matcher wrote at ingest would be exactly +the mechanical join itd-180 forbade, so the route went back to the person as +DQ2b. + +The person ruled on 2026-09-30, verbatim: "reading findings and repeats: ALL +THREE — (a) store a 'same as / builds on' link on the stored reading finding +(this REVERSES itd-180's ruling that spotting a recurrence is the researcher's +warm work, never a mechanical join; record it as a typed 'reverses' decision +against itd-180), (b) at storing time also SHOW the likely repeats, and (c) +check again at the promote step." + +## Decision + +We will match every reading finding against the record when it is stored, +write its likely repeats onto it, show them, and match again when an accepted +item is promoted. + +1. **The link is stored on the finding.** A reading record carries the two + typed links the filing-time match writes: `duplicates:` ("same as") and + `refines:` ("builds on"), each a list of `iss-N`, `itd-N` or `rdi-N`. The + reading schema's allow-list gains exactly those two keys. +2. **The match is capture's.** `reading ingest` runs the one canonical match + (`internal/core/record/match`) with capture's threshold, link cap, + minimum-term floor and configuration. The candidates are the open and + resolved issues, the intents, and every reading item already in the ledger, + so a finding a later reading returns again is linked to the item that first + carried it. The compared text is the finding's own words: the pattern and + the position's body, never the envelope every item of a run shares. The + items one ingest stores are never candidates for each other. +3. **The repeats are shown.** The ingest prints each stored item's match and + carries it in `--json` as `matches`, through the renderer the capture verb + uses. +4. **The promote step matches again.** `capture promote <rdi-N>`, which mints + an intent draft, compares the item's finding with the record a capture is + compared with and writes the links onto the draft as a capture writes them. +5. **Every link is a proposal.** As on an issue, a person confirms a link by + leaving it and removes it by deleting the line. The match never refuses a + write, never proposes `reverses` or `supersedes`, and never names a + disposition state. No disposition state means "already covered"; the + researcher's `recurs` citation on a disposition stays the confirmed form of + a recurrence, and the stored link is the machine's proposal of one. + +## Alternatives Considered + +- **Report the likely repeats at ingest and write no link** (the lane's + recommendation in DQ2b). It kept itd-180 whole, but left the proposal + nowhere durable: a repeat seen once in a terminal is lost to every later + reader of the ledger. Rejected by the person. +- **Match only at the promote step.** The one point where a reading item + becomes a record the match already covers, so no schema change was needed. + It misses every finding that is never promoted, which is most of them. + Kept as (c), not as the whole answer. +- **Keep recurrence entirely warm** (itd-180 as shipped). Rejected: the person + ruled that the machine proposes a repeat when the finding is stored. +- **Store, show and re-check (chosen).** All three, as ruled. + +## Consequences + +- itd-180's ruling "recurrence matching is warm work" is reversed and its text + says so, citing this record. The assembler still never hands a reading the + ledger's links: a candidate projection carries two named fields only, and + the dispositions stay excluded. +- A reading record may carry `duplicates:` and `refines:`; the writer's + validator and the committed-tree gate accept them, and the gate resolves + each named id. +- `reading ingest` and `capture promote <rdi-N>` read the ledger, the intent + store and the reading store under their locks. An unreadable candidate set + files the item unlinked and says why. +- The brief's capture and reading chapters, and the capture and reading + command pages, state the match on this route. diff --git a/.abcd/development/decisions/adrs/README.md b/.abcd/development/decisions/adrs/README.md index e93c60324..2be0c90ce 100644 --- a/.abcd/development/decisions/adrs/README.md +++ b/.abcd/development/decisions/adrs/README.md @@ -186,3 +186,4 @@ The intent lint (a Go implementation) extends to verify these reciprocally. | [adr-2609291342092738](2609291342092738-a-drain-takes-an-issue-alone-only-when-its-fields-say-it.md) | A drain takes an issue alone only when its fields say it needs no decision | accepted | 2026-09-29 | | [adr-2609292012006845](2609292012006845-now-next-and-later-list-an-intent-in-a-lane-under-now-only.md) | Now, Next and Later list an intent in a lane under Now only; phases stay retired (supersedes adr-2609212115255771) | accepted | 2026-09-29 | | [adr-2609292116133348](2609292116133348-a-dependency-bump-inside-the-bound-is-re-authored-as-the.md) | A dependency bump inside the bound is re-authored as the owner and only pushed by an App | accepted | 2026-09-29 | +| [adr-2609300821558671](2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md) | A reading finding is matched against the record when it is stored, reversing itd-180's warm-work-only rule | accepted | 2026-09-30 | diff --git a/.abcd/development/intents/shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md b/.abcd/development/intents/shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md index 4b290d2e6..257620003 100644 --- a/.abcd/development/intents/shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md +++ b/.abcd/development/intents/shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md @@ -64,14 +64,22 @@ register and the iss-2608220750029991 triage-route seed. (exit condition required; availability at the widening position still open). The grounds field is `disposition_grounds`, required on every state except `held`; what it must contain varies by state, enforced by - lint rather than by four fields. Free text, not enumerations. Nothing - meaning "already covered" exists in any position; an undispositioned - item is reported as outstanding, not named as a state. The disposition + lint rather than by four fields. Free text, not enumerations. No + disposition state means "already covered" in any position; an + undispositioned item is reported as outstanding, not named as a state. + The likely repeat the filing-time match writes onto a stored item + (`duplicates:` / `refines:`, below) is a proposal on the item, never a + state of its answer. The disposition record reads the envelope's position to validate its own state — a coupling the schema carries and the lint checks — and the admitted-against-declined count at the widening position is the ownership evidence, queryable without reading prose. -- The recurrence link, on the warm side: a disposition may cite prior +- The recurrence link, in two halves ([adr-2609300821558671](../../decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md), + ruling DQ2b of 2026-09-30). The mechanical half: `reading ingest` + matches each stored finding against the issues, the intents and every + earlier reading item with the filing-time match, writes a likely repeat + onto the item as `duplicates:` or `refines:`, and shows it; the promote + step matches the minted draft again. The warm half: a disposition may cite prior item identifiers (`recurs`), and a re-acceptance or re-rejection made against evidence of persistence carries that citation — the stronger record recurrence-is-signal describes, and the answer to the @@ -124,11 +132,14 @@ register and the iss-2608220750029991 triage-route seed. surprise entry and the disposition are different acts and must be distinguishable records; reusing the issue states collapses them and misdescribes all three. -- **Recurrence matching is warm work (per the closing-run ruling):** run-scoped identifiers - join nothing mechanically; the researcher recognises a recurrence - against the ledger, and the recognition is itself a disposition - judgement — the `recurs` citation in scope is that recognition's - recorded form. +- **Recurrence matching is mechanical and confirmed by the researcher + (ruling DQ2b, 2026-09-30, + [adr-2609300821558671](../../decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md), + which reverses the closing-run ruling that it is warm work only):** the + filing-time match links a stored finding to its likely repeats, among + them the earlier reading items, and the link is a proposal a person + keeps or deletes; the researcher's recognition of a recurrence is still + a disposition judgement, and the `recurs` citation is its recorded form. - **Where an accepted item goes (per the acceptance-routing ruling):** acceptance is one record; the action is a separate admission and build, joined by the item identifier (forward on `promoted_to` (historical), back in `origin` with diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 25682bb00..8dd15c26b 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2600,3 +2600,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-29 — The 2026-09-29 entry applying ruling J15 names one plain-path consequence of the ledger forgetting a stopped domain, a domain edited out of `rules.json` and back; there is a second, which needs no edit at all: a dormant domain activated with `*NAME` is in force for that prompt alone, so a prompt without the prefix drops it from the active set and the next `*NAME` renders it again (review-routerRemoval finding 2; recorded by fix round fix-routerRemoval of autonomous run A; Refs: iss-2608261550580260). Both follow from J15, since the domain left the set in between, and both are documented in brief `05-internals/03-configuration.md` "The prompt router's output"; the rule-loader text in `AGENTS.md` and the managed-repository marker block qualify "never re-injects unchanged rules" to a domain that stays in force. The earlier entry stands as written. - 2026-09-29 — Every new issue carries a remedy, and abcd's own automatic filers write one machine value when they have no fix (the product thinker's rulings BX3 and H12 of 2026-09-29, applied by lane remedyRequired of autonomous run A; partial of itd-82, whose decision 6 already required the field). BX3, verbatim: "REFUSE the filing; every new issue must carry remedy:". H12, verbatim: "'NO FIX YET' ALLOWED: automatic filers may write remedy 'none (filed automatically)'; the record is filed, drain skips it until a person writes a real remedy." As built: `capture` refuses a new issue with no remedy or a blank one, exit 2 and nothing written, naming `--remedy` and the machine value; the value is spelt once, `issueschema.MachineRemedy`, and written by every in-binary filer (the consistency pass, and an inbox report promoted without a remedy of its own, whose own remedy otherwise becomes the issue's); `abcd drain --dry-run` lists a record carrying it as ineligible, naming the automatic filer and the verb that answers it; and `capture remedy <iss-N> "<fix>"` writes or replaces the remedy on an open issue. A record filed before the rule carries none, stays readable and valid, and is listed as ineligible. The one-line capture friction that `commands/capture.md` and the principle `adversarial-review-scales-with-blast-radius` promised is tightened by the ruling, and both now say so. A person typing the machine value is REFUSED, by `capture --remedy` and by `capture remedy`, compared trimmed and case-folded: the value is useful only while it means that a machine filed the record, and a person has either a fix to name or the choice not to file; allowing it would let a hand-filed record pass as machine-filed and be skipped silently. A remedy chosen in an autonomous run cites its grounds, a prior-art or state-of-the-art check where the fix depends on outside practice (principle `prefer-sota`), in the capture's text; nothing checks that mechanically yet. - 2026-09-30 — Pending the person's ruling CL1, a promoted inbox report files the machine value: outside text never becomes a drain-eligible remedy without a person naming it (fix round of lane remedyRequired, autonomous run A, closing the review's trust finding on the entry of 2026-09-29 above). As built: `inbox promote` always writes `remedy: none (filed automatically)` (`issueschema.MachineRemedy`), whatever the report proposes, so `abcd drain --dry-run` lists the issue as ineligible; the sender's proposal stays in the issue's text under "Remedy the reporter proposes:", scrubbed like every other value the report carries, for a person to adopt with `capture remedy`. This narrows the entry above, which filed a report's own remedy as the issue's; the ruling CL1 may widen it again. +- 2026-09-30 — A reading finding is matched against the record when it is stored, and itd-180's ruling that recurrence matching is warm work is reversed (the person's ruling DQ2b of 2026-09-30, recorded as adr-2609300821558671, which carries the typed `reverses` link to itd-180; applied by lane filingReading of autonomous run A; Refs: iss-2609281911024185). DQ2b, verbatim: "reading findings and repeats: ALL THREE — (a) store a 'same as / builds on' link on the stored reading finding (this REVERSES itd-180's ruling that spotting a recurrence is the researcher's warm work, never a mechanical join; record it as a typed 'reverses' decision against itd-180), (b) at storing time also SHOW the likely repeats, and (c) check again at the promote step." As built: a reading record may carry `duplicates:` and `refines:` naming an `iss-N`, `itd-N` or `rdi-N`; `reading ingest` runs capture's one filing-time match on each finding's pattern and body (never the envelope), against the open and resolved issues, the intents and every earlier reading item, never against another item of the same ingest, writes the links and prints the match (and carries it as `matches` in `--json`); `capture promote <rdi-N>` matches the draft it mints on the item's finding and links it as a capture is linked. Earlier reading items are candidates on this route only, because a recurrence is a finding a later reading returns again; a capture's candidate set is unchanged. From b796ac79c55848eaaafbff31f4b0dc59d657e1dd Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:37:08 +0100 Subject: [PATCH 55/69] feat(reading): match every stored finding and the draft its promotion mints Ruling DQ2b (adr-2609300821558671) finishes the filing-time match on the third route of iss-2609281911024185, the reading ingest, in all three ways the person chose. The one canonical match is extended, not copied. - (a) A reading record may carry `duplicates:` and `refines:` naming an iss-N, itd-N or rdi-N (issueschema.ReadingKnown gains exactly those two keys; the writer's validator checks the ids). `reading ingest` runs capture's match on each item under the ledger lock, with the same threshold, link cap and configuration. It compares the finding's own words (pattern and body, never the envelope every item of a run shares) with the open and resolved issues, the intents and every earlier reading item, and never with an item the same ingest files. - (b) The ingest shows each record's match: `matches` in --json, and renderMatch under each record id in the plain rendering. - (c) `capture promote <rdi-N>` matches the draft it mints on the item's finding (intent.Matcher.Text), against the set a capture is compared with, and links the draft as a capture is linked; PromoteResult.Match carries it and the verb prints it. capture/match.go splits matchAndLink into rankExcept and linkMatches, so the issue and reading families share one scorer and one link writer and differ only in the validator. Earlier reading items are candidates on the reading route only: a capture's candidate set is unchanged. Docs: brief 04-surfaces/06-capture and 23-reading, commands/capture.md and commands/reading.md, the --recurs flag help and the ingest help (go generate). Refs: iss-2609281911024185 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 13 +- .../brief/04-surfaces/23-reading.md | 14 ++ commands/capture.md | 19 +- commands/reading.md | 13 +- docs/reference/cli/commands.md | 7 +- internal/core/capture/match.go | 96 +++++++- internal/core/capture/promote.go | 26 ++- internal/core/capture/reading.go | 87 ++++++- internal/core/capture/reading_match_test.go | 215 ++++++++++++++++++ internal/core/intent/create.go | 21 +- internal/core/intent/match.go | 5 + internal/core/issueschema/reading.go | 8 +- internal/core/reading/ingest.go | 24 +- internal/surface/cli/cli.go | 18 +- internal/surface/cli/reading.go | 25 +- .../surface/cli/reading_match_surface_test.go | 164 +++++++++++++ 16 files changed, 721 insertions(+), 34 deletions(-) create mode 100644 internal/core/capture/reading_match_test.go create mode 100644 internal/surface/cli/reading_match_surface_test.go diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index 1eb3daa5e..59525ac4a 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -124,7 +124,12 @@ and prose, and each finding the consistency pass files by its summary and explanation. Neither compares the lines every record it files carries (the inbox's provenance, the pass's evidence line), on which two unrelated records would match, and the consistency pass never compares a record the same pass -filed. +filed. The reading ingest runs it on every stored finding +([`23-reading.md`](23-reading.md)), and promoting an accepted reading item +matches the draft it mints on the item's pattern and body, since the pattern +alone is too short to compare, and links the draft as a quoted-text create is +linked (ruling DQ2b, +[adr-2609300821558671](../../decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md)). 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 @@ -209,8 +214,10 @@ keyed reading record. Once an item already carries a standing answer, a new one must cite it as superseded: that is the only exit from a hold, and what makes the standing disposition the one no sibling supersedes. An item the researcher recognises as one that has come round before says so as a recurrence, -naming the earlier items it recurs from; that is a recorded recognition, never a -join a machine derived. Two hold-shaping flags are reserved and dormant, and a +naming the earlier items it recurs from; that is the researcher's confirmed +recognition. The machine's proposal of the same thing is the `duplicates:` or +`refines:` link the reading ingest writes onto the item +([adr-2609300821558671](../../decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md)). Two hold-shaping flags are reserved and dormant, and a populated value is refused until activation is ruled. **At the widening position the order is fixed: characterise first, admit diff --git a/.abcd/development/brief/04-surfaces/23-reading.md b/.abcd/development/brief/04-surfaces/23-reading.md index 57eb47e33..127c72a93 100644 --- a/.abcd/development/brief/04-surfaces/23-reading.md +++ b/.abcd/development/brief/04-surfaces/23-reading.md @@ -275,6 +275,20 @@ that run's own commit marker: A refused run reports the orphans it left in place instead of sweeping them: the sweep is a delete in the committed tier, and a refused run never reaches one. +**Every stored finding is matched against the record** (ruling DQ2b, +[adr-2609300821558671](../../decisions/adrs/2609300821558671-a-reading-finding-is-matched-against-the-record-when-it-is.md)). +The ingest runs capture's filing-time match on each item as it lands, with the +same threshold, link cap and configuration (see +[`06-capture.md`](06-capture.md)): the item's pattern and body are compared +with the open and resolved issues, the intents and every earlier reading item, +and never with another item of the same ingest or with the envelope every item +of a run shares. A likely repeat is written onto the reading record as +`duplicates:` or `refines:`, and the ingest shows each item's match, printed and +as `matches` in the JSON. A link is a proposal the researcher keeps or deletes; +the confirmed form of a recurrence stays the disposition's `recurs` citation. +The match never refuses the ingest: a short finding, an unread record set or a +refused configuration files the item unlinked and says why. + ## What this surface does not claim It never runs a reading. It produces the input a reading would be given; diff --git a/commands/capture.md b/commands/capture.md index 96363b10a..585cf5617 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -544,13 +544,16 @@ hand, until exactly one does. The standing disposition of an item is the one no sibling supersedes, and the superseded record stays in place, because a hold that vanished when it was answered would take its own exit condition with it. `--recurs` cites prior item ids — the -recorded form of a warm recognition that something has come back, never a -mechanical join and never a state of its own. +researcher's confirmed recognition that something has come back, never a state +of its own. The machine's proposal of a repeat is the `duplicates:` or +`refines:` link `reading ingest` writes onto the item; a recurrence the +researcher confirms is cited here. `--hold-frame-location` and `--hold-moscow` are **reserved and dormant**: the grammars are stated and a populated value is refused until activation is ruled. -Nothing means "already covered" — an item nobody has answered is reported as -outstanding by `abcd lint`, never named as a state. +No state means "already covered": an item nobody has answered is reported as +outstanding by `abcd lint`, never named as a state, and a stored link to a +likely repeat is a proposal on the item, not an answer to it. **At the widening position, characterise first and admit second.** No disposition in any state (`accepted`, `declined` or `held`) and no admission is @@ -739,6 +742,14 @@ intent. Its Press Release seed names no item ("Seeded by promotion from a readin item"): that section is projected to a later reading, and no reading sees another's output. The item's own text stays in the reading record. +Promoting a reading item matches the draft it mints against the record, as a +capture is matched: the item's pattern and body are compared with the open and +resolved issues and the intents, and each likely double is written onto the +draft as `duplicates:` or `refines:`. The JSON carries it as `match`, and the +plain rendering prints each link written, or why nothing was compared. Relay the +match; a person keeps a link or deletes its line. Link mode mints nothing and +matches nothing. + For a reading item the JSON's `issue_status` carries the **standing disposition's state** (`accepted`), not a status folder: that family's status signal is the keyed disposition, and it has no folder to name. diff --git a/commands/reading.md b/commands/reading.md index 46dbaf8ad..2fa83f81f 100644 --- a/commands/reading.md +++ b/commands/reading.md @@ -430,7 +430,18 @@ records. The ids a sweep removed are reported however the invocation ends. One ingest runs at a time in a checkout: a second waits, and reports contention rather than sweeping the first one's records away. -Report from the JSON: `run_id`, `records`, `refused_items`, +**Each stored finding is matched against the record.** The ingest compares each +item's pattern and body with the open and resolved issues, the intents and +every earlier reading item, never with another item of the same run, and writes +a likely repeat onto the reading record as `duplicates:` ("same as") or +`refines:` ("builds on"). The JSON's `matches` lists, per record, the links +written, the matches past the cap, and the near misses with their scores; the +plain rendering prints them under each record id. The match is a lexical +heuristic and never refuses the ingest. Relay the likely repeats: the +researcher keeps a link or deletes its line, and cites a confirmed recurrence +with `capture disposition --recurs`. + +Report from the JSON: `run_id`, `records`, `matches`, `refused_items`, `cleared_stages`, `rolled_back_records`, `pending_stages`, and `run_record` — or, on a refusal that recorded one, `refusal_record`. A refusal renders the JSON whenever it has one of these to disclose, so read it on exit 2 as well. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index ba501dd52..bf662abcd 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -397,7 +397,7 @@ Answer one reading item with a disposition record: Writes the record keyed to th --grounds string disposition_grounds: why this answer (free text; required on every state except held) --hold-frame-location string RESERVED (dormant): the frame element a hold sits at; a populated value is refused until activation is ruled --hold-moscow string RESERVED (dormant): must | should | could | wont; a populated value is refused until activation is ruled - --recurs string comma-separated prior rdi-ids this item recurs from — the recorded form of a warm recognition, never a mechanical join + --recurs string comma-separated prior rdi-ids this item recurs from — the researcher's confirmed recognition; the ingest's duplicates/refines link is only a proposal --state string the answer: accepted | rejected | declined | held (availability varies by the item's position) --supersedes string the standing dsp-N this answer replaces; required once an item already carries one ``` @@ -2548,6 +2548,11 @@ run never happened; where the marker is there the run stands and only the stage refused run reports the orphans it left in place, and the ids a sweep removed are reported as rolled_back_records on every exit, including a failing one. +Every stored finding is matched against the record as a capture is: its pattern and body are +compared with the open and resolved issues, the intents and every earlier reading item, never +with another item of the same run, and a likely repeat is written onto the reading record as a +duplicates: or refines: link and shown, printed and as matches in --json. + **Flags:** ``` diff --git a/internal/core/capture/match.go b/internal/core/capture/match.go index a7cb9090c..0e0d8be47 100644 --- a/internal/core/capture/match.go +++ b/internal/core/capture/match.go @@ -2,11 +2,16 @@ package capture import ( "fmt" + "os" + "path/filepath" "slices" "strings" "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/readingitem" "github.com/intentdriven/abcd/internal/core/record/match" + "github.com/intentdriven/abcd/internal/core/recordid" ) // match.go is the ledger's half of the filing-time match @@ -87,14 +92,29 @@ func matchAndLink(repoRoot, issuesRoot string, cfg match.Config, text string, ex o := match.Unread(cfg.Threshold, err) return content, &o } + return linkMatches(rankExcept(text, cands, except, cfg.Threshold), content, fm, validateStrict) +} + +// rankExcept ranks text against every candidate not named in except: the +// records a filer has already filed in the same pass, which are never doubles +// of each other. +func rankExcept(text string, cands []match.Candidate, except []string, threshold float64) match.Outcome { if len(except) > 0 { - cands = slices.DeleteFunc(cands, func(c match.Candidate) bool { return slices.Contains(except, c.ID) }) + cands = slices.DeleteFunc(slices.Clone(cands), func(c match.Candidate) bool { return slices.Contains(except, c.ID) }) } - o := match.Rank(text, cands, cfg.Threshold) + return match.Rank(text, cands, threshold) +} + +// linkMatches writes an outcome's links into a record's rendered content and +// validates the frontmatter they join with the record family's own validator. +// A link the validator refuses comes back as an outcome saying so, with the +// content unlinked: the match never refuses the write. +func linkMatches(o match.Outcome, content string, fm map[string]any, validate func(map[string]any) error) (string, *match.Outcome) { links := o.Links() if len(links) == 0 { return content, &o } + var err error linked := content withLinks := make(map[string]any, len(fm)+len(links)) for k, v := range fm { @@ -111,7 +131,7 @@ func matchAndLink(repoRoot, issuesRoot string, cfg match.Config, text string, ex } } if err == nil { - err = validateStrict(withLinks) + err = validate(withLinks) } if err != nil { o.Skipped = fmt.Sprintf("the links could not be written (%v), so the record is filed unlinked", err) @@ -122,3 +142,73 @@ func matchAndLink(repoRoot, issuesRoot string, cfg match.Config, text string, ex } return linked, &o } + +// readingMatchText is a reading item's comparable text: the pattern it names +// and the body its position declares, in the declared order. That is the +// finding in the instrument's own words. The envelope (run, manifest, +// position, regime) is left out, because every item of a run carries the same +// envelope and two findings would otherwise match on it alone. +func readingMatchText(fm map[string]any) string { + parts := []string{asString(fm["pattern"])} + for _, f := range issueschema.ReadingBodyFields[asString(fm["position"])] { + parts = append(parts, asString(fm[f])) + } + return strings.Join(parts, "\n") +} + +// readingFilingCandidates is the candidate set a stored reading finding is +// matched against (ruling DQ2b, adr-2609300821558671): the capture set, and +// every reading item already in the ledger, so a finding a later reading +// returns again is linked to the item that first carried it. A reading record +// the family's validator refuses is not a candidate, as a skipped issue is not +// one; a readings directory that cannot be listed makes the set unknown, which +// is an error the caller reports as an unread match. +func readingFilingCandidates(repoRoot, issuesRoot string, cfg match.Config) ([]match.Candidate, error) { + out, err := matchCandidates(repoRoot, issuesRoot, cfg) + if err != nil { + return nil, err + } + readingsRoot := filepath.Join(issuesRoot, issueschema.ReadingsDir) + if err := readingitem.RefuseSymlinkedDir(readingsRoot); err != nil { + return nil, wrapLocatorErr(err) + } + runs, err := os.ReadDir(readingsRoot) + if err != nil { + if os.IsNotExist(err) { + return out, nil + } + return nil, err + } + for _, run := range runs { + if !recordid.ValidReadingRunID(run.Name()) { + continue + } + runDir := filepath.Join(readingsRoot, run.Name()) + if err := readingitem.RefuseSymlinkedDir(runDir); err != nil { + return nil, wrapLocatorErr(err) + } + if !run.IsDir() { + continue + } + items, err := os.ReadDir(runDir) + if err != nil { + return nil, err + } + for _, it := range items { + id, ok := strings.CutSuffix(it.Name(), ".md") + if !ok || !recordid.ValidReadingItemID(id) || !it.Type().IsRegular() { + continue + } + content, err := readRecordGuarded(filepath.Join(runDir, it.Name())) + if err != nil { + continue + } + fm, _, err := parseFrontmatterAndBody(content) + if err != nil || validateReadingStrict(fm) != nil { + continue + } + out = append(out, match.Candidate{ID: id, Text: readingMatchText(fm)}) + } + } + return out, nil +} diff --git a/internal/core/capture/promote.go b/internal/core/capture/promote.go index f2df7efe5..23155cbce 100644 --- a/internal/core/capture/promote.go +++ b/internal/core/capture/promote.go @@ -10,6 +10,7 @@ import ( "github.com/intentdriven/abcd/internal/core/intent" "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/provenance" + "github.com/intentdriven/abcd/internal/core/record/match" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" ) @@ -37,6 +38,12 @@ type PromoteRequest struct { // derives extracted-from-record from what it did rather than from what it was // told. ProductionMode string + // Match, when non-nil, runs the filing-time match on the draft a reading + // item's promotion mints (ruling DQ2b): the item's finding, in its own + // words, is compared with the open and resolved issues and the intents, + // and each likely double is written onto the draft as capture writes it. + // nil, stamp-only mode, and the issue route mint unmatched. + Match *match.Config } // PromoteResult is the outcome of a successful Promote. Paths are @@ -65,6 +72,8 @@ type PromoteResult struct { // than not recording it. Redacted int `json:"redacted,omitempty"` Degraded string `json:"redaction_degraded,omitempty"` + // Match is the minted draft's filing-time match, when one was asked for. + Match *match.Outcome `json:"match,omitempty"` } // stampWriteHook, when non-nil, replaces the atomic in-place write inside @@ -539,6 +548,7 @@ func promoteReadingItem(repoRoot, issuesRoot string, req PromoteRequest) (Promot state := issueschema.DispositionAccepted var itdID, intentPath, backEdgeKept string + var matched *match.Outcome linked := req.LinkIntent != "" if linked { if !reItdID.MatchString(req.LinkIntent) { @@ -581,7 +591,17 @@ func promoteReadingItem(repoRoot, issuesRoot string, req PromoteRequest) (Promot } seed := "Graduated from `" + req.ID + "` (" + state + "): " + title + ". Read that reading record for the instrument's own text." - it, err := intent.CreateDraft(repoRoot, intent.DraftOptions{ + // The promote step matches again (ruling DQ2b, adr-2609300821558671): + // the draft is compared, by the item's finding rather than its one-line + // pattern, with the record a capture is compared with, and linked as a + // capture is linked. + var m *intent.Matcher + if req.Match != nil { + cfg := *req.Match + m = &intent.Matcher{Threshold: cfg.Threshold, Text: readingMatchText(fm), + Candidates: func() ([]match.Candidate, error) { return matchCandidates(repoRoot, issuesRoot, cfg) }} + } + it, outcome, err := intent.CreateDraftMatched(repoRoot, intent.DraftOptions{ Slug: slug, Title: title, SeedBody: seed, @@ -594,11 +614,12 @@ func promoteReadingItem(repoRoot, issuesRoot string, req PromoteRequest) (Promot Kind: provenance.KindContributedByReading, Run: run, Item: req.ID, }, ProductionMode: req.ProductionMode, + Match: m, }) if err != nil { return PromoteResult{}, err } - itdID, intentPath = it.ID, it.Path + itdID, intentPath, matched = it.ID, it.Path, outcome } if beforeStampHook != nil { @@ -662,6 +683,7 @@ func promoteReadingItem(repoRoot, issuesRoot string, req PromoteRequest) (Promot IntentPath: intentPath, Linked: linked, BackEdgeKept: backEdgeKept, + Match: matched, }, nil } diff --git a/internal/core/capture/reading.go b/internal/core/capture/reading.go index c965f6a87..f9fbd3638 100644 --- a/internal/core/capture/reading.go +++ b/internal/core/capture/reading.go @@ -23,13 +23,16 @@ package capture import ( "errors" "fmt" + "maps" "os" "path/filepath" "regexp" + "slices" "strings" "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/readingitem" + "github.com/intentdriven/abcd/internal/core/record/match" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/termsafe" @@ -46,6 +49,10 @@ import ( // that package. var reDispositionID = regexp.MustCompile(`^` + issueschema.DispositionFamily + `-[0-9]+$`) +// readingLinkIDRe is what a reading item's `duplicates:` or `refines:` link may +// name: the records the filing-time match compares a finding with. +var readingLinkIDRe = regexp.MustCompile(`^(iss|itd|` + issueschema.ReadingItemFamily + `)-[0-9]+$`) + // ReadingItem is one thing the instrument returned: the pattern it named (an // envelope field, because a universal core condition must not live in a variant // part) plus the position-typed body. @@ -73,6 +80,14 @@ type IngestReadingRequest struct { Position string Regime string Items []ReadingItem + // Match, when non-nil, runs the filing-time match on every item as it is + // stored (ruling DQ2b, adr-2609300821558671): each finding is compared, by + // its own words, with the open and resolved issues, the intents and every + // earlier reading item, and a likely repeat is written onto the item as a + // `duplicates:` or `refines:` link and reported. The items this ingest + // files are never candidates for each other. nil stores the items + // unmatched. + Match *match.Config } // ReadingRecordRef is one written reading record: its minted id and its @@ -91,6 +106,17 @@ type IngestReadingResult struct { // Both exist so a surface can SAY the text was altered. Redacted int `json:"redacted,omitempty"` Degraded string `json:"redaction_degraded,omitempty"` + // Matches is the filing-time match's outcome per record written, in the + // order the records were, when the request asked for a match. It is kept + // off ReadingRecordRef because that type is also the run record's + // committed list, which the match does not belong in. + Matches []ReadingMatch `json:"matches,omitempty"` +} + +// ReadingMatch is one stored reading item's filing-time match. +type ReadingMatch struct { + ID string `json:"id"` + Match *match.Outcome `json:"match"` } // DispositionRequest writes one disposition, keyed to one reading item. @@ -110,8 +136,10 @@ type DispositionRequest struct { // Supersedes names the standing disposition this one replaces (dsp-N) — the // only exit from a hold. Supersedes string - // Recurs cites prior item ids: the recorded form of the researcher's warm - // recognition of a persistence. Never a mechanical join, and never a state. + // Recurs cites prior item ids: the recorded form of the researcher's + // confirmed recognition of a persistence, never a state. The machine's + // proposal of the same thing is the item's own duplicates:/refines: link, + // written by the filing-time match when the item was stored (ruling DQ2b). Recurs []string // HoldFrameLocation / HoldMoscow are the RESERVED two-axis hold field. Both // are refused while populated; the grammars are stated and dormant. @@ -212,9 +240,21 @@ func IngestReading(req IngestReadingRequest) (IngestReadingResult, error) { return err } + // The filing-time match (ruling DQ2b, adr-2609300821558671) reads its + // candidate set once, here under the lock, so the ledger it compares + // with is the one the items join. The items this ingest mints are not + // on disk yet, and are named as exceptions besides, so two findings of + // one reading are never linked to each other. + var cands []match.Candidate + var candErr error + if req.Match != nil { + cands, candErr = readingFilingCandidates(repoRoot, issuesRoot, *req.Match) + } + // Assemble and validate EVERY item before anything is written: a run that // is half-written is a visible world nobody can reconstruct. var pending []staged + var matches []ReadingMatch minted := map[string]bool{} for i, item := range items { id, err := mintUnusedItemID(issuesRoot, minted) @@ -230,6 +270,11 @@ func IngestReading(req IngestReadingRequest) (IngestReadingResult, error) { if err != nil { return fmt.Errorf("item %d: %w", i+1, err) } + if req.Match != nil { + var o *match.Outcome + content, o = matchReadingItem(*req.Match, cands, candErr, slices.Collect(maps.Keys(minted)), content, fm) + matches = append(matches, ReadingMatch{ID: id, Match: o}) + } // The record's size is DECIDED here, on the assembled bytes, because // this is the only place the exact count exists: the values are // already redacted, already escaped, and this string is what reaches @@ -271,6 +316,7 @@ func IngestReading(req IngestReadingRequest) (IngestReadingResult, error) { ID: p.id, Path: fsutil.RepoRel(repoRoot, path), }) } + result.Matches = matches return nil }) if err != nil { @@ -281,6 +327,23 @@ func IngestReading(req IngestReadingRequest) (IngestReadingResult, error) { return result, nil } +// matchReadingItem runs the filing-time match for one reading item being +// stored and writes its links into the item's content. It never refuses the +// ingest: a short finding, an unread candidate set, or a link the reading +// schema refuses comes back as the outcome's reason, with the item unlinked. +func matchReadingItem(cfg match.Config, cands []match.Candidate, candErr error, except []string, content string, fm map[string]any) (string, *match.Outcome) { + text := readingMatchText(fm) + if match.Short(text) { + o := match.Rank(text, nil, cfg.Threshold) + return content, &o + } + if candErr != nil { + o := match.Unread(cfg.Threshold, candErr) + return content, &o + } + return linkMatches(rankExcept(text, cands, except, cfg.Threshold), content, fm, validateReadingStrict) +} + // Disposition writes the researcher's answer to one reading item, into a // directory keyed by the ITEM and a file keyed by the disposition's own id. // @@ -722,6 +785,24 @@ func validateReadingStrict(fm map[string]any) error { } } + // The filing-time match's typed links (ruling DQ2b): a likely repeat of an + // issue, an intent, or an earlier reading item. + for _, key := range []string{string(match.Duplicates), string(match.Refines)} { + v, present := fm[key] + if !present { + continue + } + items, isList := v.([]string) + if !isList { + return fmt.Errorf("%w: %q must be a list", ErrMalformedFrontmatter, key) + } + for _, it := range items { + if !readingLinkIDRe.MatchString(it) { + return fmt.Errorf("%w: %s item %q does not match ^(iss|itd|%s)-[0-9]+$", + ErrMalformedFrontmatter, key, it, issueschema.ReadingItemFamily) + } + } + } if v, present := fm["related_intents"]; present { items, isList := v.([]string) if !isList { @@ -1086,7 +1167,7 @@ func isReadingEnvelopeField(key string) bool { if containsString(issueschema.ReadingRequired, key) { return true } - return key == "related_intents" + return key == "related_intents" || key == string(match.Duplicates) || key == string(match.Refines) } // isReadingBodyField reports whether key belongs to SOME position's body. diff --git a/internal/core/capture/reading_match_test.go b/internal/core/capture/reading_match_test.go new file mode 100644 index 000000000..76cdf075d --- /dev/null +++ b/internal/core/capture/reading_match_test.go @@ -0,0 +1,215 @@ +package capture + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/record/match" +) + +// reading_match_test.go proves ruling DQ2b (2026-09-30) on the reading +// family: a reading finding is matched when it is stored, its likely repeats +// are written onto it as `duplicates:` / `refines:` links and reported, and +// the promote step that mints an intent draft from it matches again. + +// A finding the reading returns in its own words, doubling plantedFinding. +var readingDouble = ReadingItem{ + Pattern: "ledger reader skips records with duplicated keys", + Body: map[string]string{ + "tension": plantedDouble, + "constraint_in_play": "every finding appears in every listing of the capture ledger", + "why_a_tension": "a record whose frontmatter carries a duplicated key disappears from every listing silently", + }, +} + +// An unrelated finding, so a run has a second item and the corpus has other +// words to weigh against. +var readingOther = ReadingItem{ + Pattern: "site builder anchors go stale", + Body: map[string]string{ + "tension": "The site builder renders a stale anchor for a heading renamed since the last build.", + "constraint_in_play": "every rendered anchor resolves to a heading on the current page", + "why_a_tension": "a renamed heading leaves the anchor pointing at nothing after the rebuild", + }, +} + +func ingestDetection(t *testing.T, repo, ir, run string, m *match.Config, items ...ReadingItem) IngestReadingResult { + t.Helper() + res, err := IngestReading(IngestReadingRequest{ + RepoRoot: repo, IssuesRoot: ir, + Run: run, Manifest: "sha256:" + strings.Repeat("a", 64), + Position: "detection", Regime: issueschema.ReadingRegime("detection"), + Items: items, Match: m, + }) + if err != nil { + t.Fatalf("IngestReading: %v", err) + } + if len(res.Records) != len(items) { + t.Fatalf("IngestReading wrote %d records, want %d", len(res.Records), len(items)) + } + return res +} + +// matchOf returns the outcome the ingest reported for record id. +func matchOf(t *testing.T, res IngestReadingResult, id string) *match.Outcome { + t.Helper() + for _, m := range res.Matches { + if m.ID == id { + return m.Match + } + } + t.Fatalf("the ingest reported no match for %s: %+v", id, res.Matches) + return nil +} + +// (a) and (b): a stored reading finding that doubles an open issue carries the +// typed link naming it, the ingest reports the match, and the linked record is +// still a record the family's own validator reads. +func TestReadingIngestLinksAFindingAnOpenRecordHolds(t *testing.T) { + repo, ir := ledger(t) + held := captureText(t, repo, ir, plantedFinding, nil) + captureText(t, repo, ir, matchFiller1, nil) + captureText(t, repo, ir, matchFiller2, nil) + + res := ingestDetection(t, repo, ir, "rdg-2609300000000001", bundled(), readingDouble) + o := matchOf(t, res, res.Records[0].ID) + if o == nil || len(o.Matches) == 0 { + t.Fatalf("no match reported: %+v", o) + } + m := o.Matches[0] + if m.ID != held.ID || !m.Linked || (m.Relation != match.Duplicates && m.Relation != match.Refines) { + t.Fatalf("match = %+v, want %s linked", m, held.ID) + } + content := readRecord(t, repo, res.Records[0].Path) + if !strings.Contains(content, "\n"+string(m.Relation)+": ["+held.ID+"]\n") { + t.Fatalf("the reading record carries no %s link naming %s:\n%s", m.Relation, held.ID, content) + } + if _, err := ValidateReadingRecord(content); err != nil { + t.Fatalf("the linked reading record does not validate: %v", err) + } +} + +// The reversal itself: a later reading that returns the same finding again is +// linked to the earlier reading's item, mechanically, at storing time. +func TestReadingIngestLinksARecurrenceOfAnEarlierReadingItem(t *testing.T) { + repo, ir := ledger(t) + captureText(t, repo, ir, matchFiller1, nil) + captureText(t, repo, ir, matchFiller2, nil) + first := ingestDetection(t, repo, ir, "rdg-2609300000000001", nil, readingDouble, readingOther) + + again := ingestDetection(t, repo, ir, "rdg-2609300000000002", bundled(), readingDouble) + o := matchOf(t, again, again.Records[0].ID) + if o == nil || len(o.Matches) == 0 || o.Matches[0].ID != first.Records[0].ID || + o.Matches[0].Relation != match.Duplicates || !o.Matches[0].Linked { + t.Fatalf("the recurrence was not linked to %s: %+v", first.Records[0].ID, o) + } + content := readRecord(t, repo, again.Records[0].Path) + if !strings.Contains(content, "\nduplicates: ["+first.Records[0].ID+"]\n") { + t.Fatalf("no duplicates link on the recurrence:\n%s", content) + } +} + +// Two findings of one reading are two findings (the consistency pass's rule): +// the items one ingest files are never candidates for each other. +func TestReadingIngestNeverLinksTwoFindingsOfOneRun(t *testing.T) { + repo, ir := ledger(t) + captureText(t, repo, ir, matchFiller1, nil) + captureText(t, repo, ir, matchFiller2, nil) + twin := readingDouble + twin.Pattern = "the ledger reader skips records carrying duplicated keys" + res := ingestDetection(t, repo, ir, "rdg-2609300000000001", bundled(), readingDouble, twin) + for _, r := range res.Records { + o := matchOf(t, res, r.ID) + for _, m := range o.Matches { + for _, same := range res.Records { + if m.ID == same.ID { + t.Fatalf("%s was matched against %s, filed by the same ingest: %+v", r.ID, same.ID, o) + } + } + } + if c := readRecord(t, repo, r.Path); strings.Contains(c, "duplicates:") || strings.Contains(c, "refines:") { + t.Fatalf("%s carries a link:\n%s", r.ID, c) + } + } +} + +// Without a match configuration the ingest files unmatched and reports none, +// so every existing caller keeps its behaviour. +func TestReadingIngestWithoutAMatchReportsNone(t *testing.T) { + repo, ir := ledger(t) + captureText(t, repo, ir, plantedFinding, nil) + res := ingestDetection(t, repo, ir, "rdg-2609300000000001", nil, readingDouble) + if len(res.Matches) != 0 { + t.Fatalf("an unmatched ingest reported matches: %+v", res.Matches) + } + if c := readRecord(t, repo, res.Records[0].Path); strings.Contains(c, "duplicates:") { + t.Fatalf("an unmatched ingest wrote a link:\n%s", c) + } +} + +// The schema widening is exactly the two typed links, each a list of record +// ids the match can name; anything else stays refused. +func TestReadingRecordTypedLinksAreRecordIDs(t *testing.T) { + base := "---\nschema_version: 1\nid: rdi-2609300000000001\nrun: rdg-2609300000000001\n" + + "manifest: sha256:beef\nposition: detection\nregime: registrative\npattern: p\n" + + "tension: t\nconstraint_in_play: c\nwhy_a_tension: w\n" + for _, ok := range []string{"duplicates: [iss-1]", "refines: [itd-2]", "duplicates: [rdi-2609300000000002]"} { + if _, err := ValidateReadingRecord(base + ok + "\n---\n"); err != nil { + t.Errorf("%q refused: %v", ok, err) + } + } + for _, bad := range []string{"duplicates: [dsp-1]", "refines: iss-1", "supersedes: [rdi-1]"} { + if _, err := ValidateReadingRecord(base + bad + "\n---\n"); !errors.Is(err, ErrMalformedFrontmatter) { + t.Errorf("%q not refused as malformed: %v", bad, err) + } + } +} + +// (c): promoting an accepted reading item mints a draft that is matched again, +// on the finding's own words, and linked as capture links. +func TestPromoteReadingItemMatchesTheDraft(t *testing.T) { + repo, ir := ledger(t) + held := captureText(t, repo, ir, plantedFinding, nil) + captureText(t, repo, ir, matchFiller1, nil) + captureText(t, repo, ir, matchFiller2, nil) + res := ingestDetection(t, repo, ir, "rdg-2609300000000001", nil, readingDouble) + item := res.Records[0].ID + if _, err := Disposition(DispositionRequest{ + RepoRoot: repo, IssuesRoot: ir, Item: item, + State: issueschema.DispositionAccepted, Grounds: "the tension is real and worth acting on", + }); err != nil { + t.Fatal(err) + } + + p, err := Promote(PromoteRequest{RepoRoot: repo, IssuesRoot: ir, ID: item, Match: bundled()}) + if err != nil { + t.Fatalf("Promote: %v", err) + } + if p.Match == nil || len(p.Match.Matches) == 0 || p.Match.Matches[0].ID != held.ID || !p.Match.Matches[0].Linked { + t.Fatalf("the promoted draft was not matched to %s: %+v", held.ID, p.Match) + } + rel := string(p.Match.Matches[0].Relation) + draft, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(p.IntentPath))) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(draft), "\n"+rel+": ["+held.ID+"]\n") { + t.Fatalf("the draft carries no %s link naming %s:\n%s", rel, held.ID, draft) + } +} + +// A promote without a match configuration mints as before and reports none. +func TestPromoteReadingItemWithoutAMatchReportsNone(t *testing.T) { + repo, ir, item := dispositionedReadingFixture(t) + p, err := Promote(PromoteRequest{RepoRoot: repo, IssuesRoot: ir, ID: item}) + if err != nil { + t.Fatal(err) + } + if p.Match != nil { + t.Fatalf("an unmatched promote reported a match: %+v", p.Match) + } +} diff --git a/internal/core/intent/create.go b/internal/core/intent/create.go index e5ed5eab9..b15f3c6ea 100644 --- a/internal/core/intent/create.go +++ b/internal/core/intent/create.go @@ -205,10 +205,11 @@ type DraftOptions struct { // provenance.DefaultMode, so a draft written through a command carries the key // whatever the caller says. ProductionMode string - // Match, when non-nil, matches the draft's title and press release against - // the record under the mint lock and writes each likely double as a typed - // link (match.go). The promote route passes none: its draft is joined to - // the record it graduated from already. + // Match, when non-nil, matches the draft's title and press release (or the + // matcher's own Text) against the record under the mint lock and writes + // each likely double as a typed link (match.go). The issue promote route + // passes none; the reading-item route passes one on request (ruling DQ2b), + // comparing the item's finding. Match *Matcher } @@ -232,6 +233,12 @@ func CreateDraft(repoRoot string, opts DraftOptions) (Intent, error) { return it, err } +// CreateDraftMatched is CreateDraft returning the filing-time match's outcome +// too, nil when opts asked for none. +func CreateDraftMatched(repoRoot string, opts DraftOptions) (Intent, *match.Outcome, error) { + return createDraftMatched(repoRoot, opts) +} + // createDraftMatched is CreateDraft returning the filing-time match's outcome too, // nil when opts asked for none. func createDraftMatched(repoRoot string, opts DraftOptions) (Intent, *match.Outcome, error) { @@ -321,7 +328,11 @@ func createDraftMatched(repoRoot string, opts DraftOptions) (Intent, *match.Outc // it reads is the record the draft is written into. var links map[match.Relation][]string if opts.Match != nil { - outcome = runMatch(opts.Match, opts.Title+"\n"+opts.PressRelease) + text := opts.Title + "\n" + opts.PressRelease + if opts.Match.Text != "" { + text = opts.Match.Text + } + outcome = runMatch(opts.Match, text) links = outcome.Links() } content := seedDraft(id, opts, stamp, links) diff --git a/internal/core/intent/match.go b/internal/core/intent/match.go index b505bb29a..d83da4328 100644 --- a/internal/core/intent/match.go +++ b/internal/core/intent/match.go @@ -90,6 +90,11 @@ func matchTextOf(id, content string) MatchText { type Matcher struct { Threshold float64 Candidates func() ([]match.Candidate, error) + // Text, when non-empty, is the text the match compares in place of the + // draft's title and press release: the source record's own words, for a + // draft minted from a record whose title alone is too short to compare + // (a promoted reading item's pattern, ruling DQ2b). + Text string } // Created is a quoted-text create's result: the draft, and the match's diff --git a/internal/core/issueschema/reading.go b/internal/core/issueschema/reading.go index 3277703aa..9c3b67c39 100644 --- a/internal/core/issueschema/reading.go +++ b/internal/core/issueschema/reading.go @@ -128,7 +128,7 @@ var ReadingRequired = []string{ } // ReadingKnown is the reading record's additionalProperties:false allow-list: -// the envelope, every body field of every position, and the two optional +// the envelope, every body field of every position, and the optional // properties below. A key outside it is refused, exactly as it is on an issue. var ReadingKnown = readingKnown() @@ -274,6 +274,12 @@ func readingKnown() map[string]bool { // here (with the item named in the draft's related_issues) is what joins // the two (itd-4 AC3). "related_intents": true, + // duplicates and refines are the filing-time match's typed links + // (ruling DQ2b, adr-2609300821558671): the likely repeat of an issue, + // an intent or an earlier reading item, written when the item is stored + // and confirmed or removed by a person. + "duplicates": true, + "refines": true, } for _, k := range ReadingRequired { known[k] = true diff --git a/internal/core/reading/ingest.go b/internal/core/reading/ingest.go index f5a73b2f3..2dfb1db3b 100644 --- a/internal/core/reading/ingest.go +++ b/internal/core/reading/ingest.go @@ -51,6 +51,7 @@ import ( "github.com/intentdriven/abcd/internal/core/capture" "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/record/match" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/termsafe" @@ -383,15 +384,22 @@ type IngestRequest struct { // does not read OutputPath again, so what was routed and reported is what // is ingested. Nil means the ingest reads OutputPath itself. Output []byte + // Match, when non-nil, runs the filing-time match on every item stored + // (ruling DQ2b, adr-2609300821558671); capture.IngestReadingRequest.Match + // says what it compares and writes. nil stores the items unmatched. + Match *match.Config } // IngestResult is what an ingest did. type IngestResult struct { - RunID string `json:"run_id"` - Position Position `json:"position"` - Regime string `json:"regime"` - Records []capture.ReadingRecordRef `json:"records"` - RefusedItems []ItemRefusal `json:"refused_items,omitempty"` + RunID string `json:"run_id"` + Position Position `json:"position"` + Regime string `json:"regime"` + Records []capture.ReadingRecordRef `json:"records"` + // Matches is the filing-time match's outcome per stored record, when the + // request asked for one: the likely repeats the ingest linked and listed. + Matches []capture.ReadingMatch `json:"matches,omitempty"` + RefusedItems []ItemRefusal `json:"refused_items,omitempty"` // RefusedCount is how many items were refused in total. RefusedItems is // capped — the item count is payload-chosen — so the two differ when a run // refused more than the cap, and the count is what nothing truncates. @@ -691,7 +699,7 @@ func ingestUnderLock(root *os.Root, repoRoot string, req IngestRequest, res *Ing return err } - return write(root, repoRoot, res, out, manifest, def, free, items) + return write(root, repoRoot, res, out, manifest, def, free, items, req.Match) } // leftPending is the orphans found minus the stages that were cleared: what a @@ -864,7 +872,7 @@ func resolveParkedManifest(root *os.Root, repoRoot string, out Output) (Manifest // write is the staged-write protocol. Nothing durable exists for the run until // step 1 has already validated everything, and the run metadata is written last. func write(root *os.Root, repoRoot string, res *IngestResult, out Output, m Manifest, def Definition, - free payloadField, items []capture.ReadingItem) error { + free payloadField, items []capture.ReadingItem, mc *match.Config) error { stageRel := IngestStageDir + "/" + out.RunID marker := stageMarker{Type: StageType, RunID: out.RunID, Records: []string{}} if err := writeJSONIn(root, stageRel+"/"+stageFileName, marker); err != nil { @@ -881,8 +889,10 @@ func write(root *os.Root, repoRoot string, res *IngestResult, out Output, m Mani Position: string(def.Position), Regime: def.Regime, Items: items, + Match: mc, }) res.Records = written.Records + res.Matches = written.Matches res.Redacted = written.Redacted noteDegraded(res, written.Degraded) if err != nil { diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 4d921ff2b..c4c7e7efd 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -4682,13 +4682,26 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if err != nil { return err } + // A reading item's promotion matches the draft it mints (ruling + // DQ2b, adr-2609300821558671), configured as the capture verb's is + // and never a refusal. The issue route and link mode mint nothing + // the match would compare. + var mc *match.Config + var matchRefused *match.Outcome + readingMint := strings.HasPrefix(args[0], issueschema.ReadingItemFamily+"-") && promoteIntent == "" + if readingMint { + mc, matchRefused = resolveMatch(cmd.ErrOrStderr(), "capture promote", repoRoot) + } res, err := capture.Promote(capture.PromoteRequest{ RepoRoot: repoRoot, ID: args[0], LinkIntent: promoteIntent, Grounds: promoteGrounds, - ProductionMode: mode, + ProductionMode: mode, Match: mc, }) if err != nil { return captureRefusal("promote", err) } + if readingMint && res.Match == nil { + res.Match = matchRefused + } return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { verb := "minted" if res.Linked { @@ -4705,6 +4718,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if res.BackEdgeKept != "" { fmt.Fprintf(w, "back_edge: kept %s\n", termsafe.Sanitize(res.BackEdgeKept)) } + renderMatch(w, res.Match) emitRedactionNote(w, res.Redacted, res.Degraded) }) }, @@ -4809,7 +4823,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { dispositionCmd.Flags().StringVar(&dispGrounds, "grounds", "", "disposition_grounds: why this answer (free text; required on every state except held)") dispositionCmd.Flags().StringVar(&dispExit, "exit-condition", "", "what would end a held disposition (required on held; a hold exits only through a superseding disposition that cites it)") dispositionCmd.Flags().StringVar(&dispSupersedes, "supersedes", "", "the standing dsp-N this answer replaces; required once an item already carries one") - dispositionCmd.Flags().StringVar(&dispRecurs, "recurs", "", "comma-separated prior rdi-ids this item recurs from — the recorded form of a warm recognition, never a mechanical join") + dispositionCmd.Flags().StringVar(&dispRecurs, "recurs", "", "comma-separated prior rdi-ids this item recurs from — the researcher's confirmed recognition; the ingest's duplicates/refines link is only a proposal") // The two-axis hold field is RESERVED and dormant. The flags exist so the // reservation is a behaviour a caller meets rather than a comment nobody // reads: a populated value is refused, and the refusal states the grammar. diff --git a/internal/surface/cli/reading.go b/internal/surface/cli/reading.go index a02f04443..b077585ce 100644 --- a/internal/surface/cli/reading.go +++ b/internal/surface/cli/reading.go @@ -36,6 +36,7 @@ import ( "strconv" "strings" + "github.com/intentdriven/abcd/internal/core/capture" "github.com/intentdriven/abcd/internal/core/oracle" "github.com/intentdriven/abcd/internal/core/reading" "github.com/intentdriven/abcd/internal/termsafe" @@ -210,7 +211,11 @@ func newReadingCommand(asJSON *bool) *cobra.Command { "marker the sweep ROLLS THAT RUN'S READING RECORDS OUT OF THE COMMITTED LEDGER, because the\n" + "run never happened; where the marker is there the run stands and only the stage goes. A\n" + "refused run reports the orphans it left in place, and the ids a sweep removed are reported as\n" + - "rolled_back_records on every exit, including a failing one.", + "rolled_back_records on every exit, including a failing one.\n\n" + + "Every stored finding is matched against the record as a capture is: its pattern and body are\n" + + "compared with the open and resolved issues, the intents and every earlier reading item, never\n" + + "with another item of the same run, and a likely repeat is written onto the reading record as a\n" + + "duplicates: or refines: link and shown, printed and as matches in --json.", Example: " abcd reading ingest --reading-json ./reading-output.json --json", Args: func(_ *cobra.Command, args []string) error { if len(args) > 0 { @@ -253,11 +258,21 @@ func newReadingCommand(asJSON *bool) *cobra.Command { if err != nil { return err } + // The filing-time match (ruling DQ2b, adr-2609300821558671), + // configured as the capture verb's is and never a refusal. + root := captureRoot(cwd) + mc, matchRefused := resolveMatch(cmd.ErrOrStderr(), "reading ingest", root) res, err := reading.Ingest(reading.IngestRequest{ - RepoRoot: captureRoot(cwd), + RepoRoot: root, OutputPath: resolved, Output: payload, + Match: mc, }) + if err == nil && mc == nil && matchRefused != nil { + for _, r := range res.Records { + res.Matches = append(res.Matches, capture.ReadingMatch{ID: r.ID, Match: matchRefused}) + } + } if err != nil { // A refusal that produced a durable record renders it before it // exits. The record path is the operator's handle on the event, @@ -626,6 +641,12 @@ func renderIngestResult(w io.Writer, res reading.IngestResult) { // elision entry names no item, so neither surface renders it as one. fmt.Fprintf(w, " %s\n", r.Render()) } + // The likely repeats of each stored finding (ruling DQ2b): the links + // written onto it, a match past the link cap, or why nothing was compared. + for _, m := range res.Matches { + fmt.Fprintf(w, " %s:\n", termsafe.Sanitize(m.ID)) + renderMatch(w, m.Match) + } if len(res.ClearedStages) > 0 { fmt.Fprintf(w, " cleared: orphaned stage(s) of %s\n", strings.Join(res.ClearedStages, ", ")) } diff --git a/internal/surface/cli/reading_match_surface_test.go b/internal/surface/cli/reading_match_surface_test.go new file mode 100644 index 000000000..54417a3e6 --- /dev/null +++ b/internal/surface/cli/reading_match_surface_test.go @@ -0,0 +1,164 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/reading" +) + +// reading_match_surface_test.go is the wiring proof for ruling DQ2b +// (adr-2609300821558671): `reading ingest` matches every finding it stores and +// shows the likely repeats, printed and in --json, and `capture promote rdi-N` +// matches the draft it mints. + +// The reading's finding, doubling fmHeld in the instrument's own words. +var fmReadingItem = map[string]any{ + "pattern": "ledger reader skips records with duplicated keys", + "tension": fmDouble, + "constraint_in_play": "every finding appears in every listing of the capture ledger", + "why_a_tension": "a record whose frontmatter carries a duplicated key disappears from every listing silently", +} + +func fmCapture(t *testing.T, repo, text string) capture.CaptureResult { + t.Helper() + res, err := capture.Capture(capture.CaptureRequest{ + RepoRoot: repo, Text: text, Severity: capture.SeverityMinor, Category: "bug", + Source: "user-observation", FoundDuring: "fixture", Remedy: issueschema.MachineRemedy, + }) + if err != nil { + t.Fatal(err) + } + return res +} + +// detectionPayloadWith writes a legal detection payload carrying the items +// given, and returns the path the verb reads. +func detectionPayloadWith(t *testing.T, runID, manifestHash string, def reading.Definition, items ...map[string]any) string { + t.Helper() + list := make([]any, 0, len(items)) + for _, it := range items { + list = append(list, it) + } + raw, err := json.Marshal(map[string]any{ + "_type": "abcd.reading.output/1", "run_id": runID, + "position": "detection", "regime": def.Regime, + "manifest_sha256": manifestHash, + "instrument": map[string]any{ + "model": "a-model", "definition_sha256": def.SHA256, + "assembler_version": reading.AssemblerVersion(), + }, + "items": list, + }) + if err != nil { + t.Fatal(err) + } + outPath := filepath.Join(t.TempDir(), "output.json") + if err := os.WriteFile(outPath, raw, 0o644); err != nil { + t.Fatal(err) + } + return outPath +} + +// TestReadingIngestShowsTheLikelyRepeats: the ingest reports the match for +// every record in --json and prints it; a later reading returning the same +// finding is linked to the earlier item as well as to the issue. +func TestReadingIngestShowsTheLikelyRepeats(t *testing.T) { + srcRoot := repoRootFromTest(t) + repo := readingRepo(t) + t.Chdir(repo) + held := fmCapture(t, repo, fmHeld) + fmCapture(t, repo, "The site builder renders a stale anchor for a heading renamed since the last build.") + fmCapture(t, repo, "The history store drops a transcript that exceeds its byte budget without saying so.") + + runID, manifestHash, def := parkedRunForIngest(t, srcRoot, repo, "detection") + raw := runCLI(t, "reading", "ingest", "--reading-json", detectionPayloadWith(t, runID, manifestHash, def, fmReadingItem), "--json") + var res struct { + Records []struct { + ID string `json:"id"` + } `json:"records"` + Matches []struct { + ID string `json:"id"` + Match *struct { + Matches []struct { + ID string `json:"id"` + Linked bool `json:"linked"` + } `json:"matches"` + } `json:"match"` + } `json:"matches"` + } + if err := json.Unmarshal(raw, &res); err != nil { + t.Fatalf("decode: %v\n%s", err, raw) + } + if len(res.Records) != 1 || len(res.Matches) != 1 || res.Matches[0].ID != res.Records[0].ID || + res.Matches[0].Match == nil || len(res.Matches[0].Match.Matches) == 0 || + res.Matches[0].Match.Matches[0].ID != held.ID || !res.Matches[0].Match.Matches[0].Linked { + t.Fatalf("ingest --json does not carry the match on %s:\n%s", held.ID, raw) + } + first := res.Records[0].ID + + runID2, manifestHash2, def2 := parkedRunForIngest(t, srcRoot, repo, "detection") + out := string(runCLI(t, "reading", "ingest", "--reading-json", detectionPayloadWith(t, runID2, manifestHash2, def2, fmReadingItem))) + if !strings.Contains(out, "matched "+first) || !strings.Contains(out, "link written") { + t.Fatalf("the ingest does not print the repeat of %s:\n%s", first, out) + } +} + +// TestCapturePromoteReadingItemReportsTheMatch: promoting an accepted reading +// item matches the draft it mints, prints the match and carries it in --json. +func TestCapturePromoteReadingItemReportsTheMatch(t *testing.T) { + repo, _ := gitRepoNoStore(t) + t.Chdir(repo) + held := fmCapture(t, repo, fmHeld) + fmCapture(t, repo, "The site builder renders a stale anchor for a heading renamed since the last build.") + body := map[string]string{} + for k, v := range fmReadingItem { + if k != "pattern" { + body[k] = v.(string) + } + } + res, err := capture.IngestReading(capture.IngestReadingRequest{ + RepoRoot: repo, Run: "rdg-2609300000000001", Manifest: "sha256:" + strings.Repeat("a", 64), + Position: "detection", Regime: issueschema.ReadingRegime("detection"), + Items: []capture.ReadingItem{ + {Pattern: fmReadingItem["pattern"].(string), Body: body}, + {Pattern: "the capture ledger reader skips records carrying duplicated keys", Body: body}, + }, + }) + if err != nil { + t.Fatal(err) + } + for _, r := range res.Records { + if _, err := capture.Disposition(capture.DispositionRequest{ + RepoRoot: repo, Item: r.ID, State: issueschema.DispositionAccepted, + Grounds: "the tension is real and worth acting on", + }); err != nil { + t.Fatal(err) + } + } + item := res.Records[0].ID + + raw := runCLI(t, "capture", "promote", item, "--json") + var p struct { + Match *struct { + Matches []struct { + ID string `json:"id"` + } `json:"matches"` + } `json:"match"` + } + if err := json.Unmarshal(raw, &p); err != nil { + t.Fatalf("decode: %v\n%s", err, raw) + } + if p.Match == nil || len(p.Match.Matches) == 0 || p.Match.Matches[0].ID != held.ID { + t.Fatalf("promote --json does not carry the match on %s:\n%s", held.ID, raw) + } + out := string(runCLI(t, "capture", "promote", res.Records[1].ID)) + if !strings.Contains(out, "matched "+held.ID) { + t.Fatalf("promote does not print the match on %s:\n%s", held.ID, out) + } +} From f2ebd0ceea7c9680b03033d2941e3dade30abdb9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:37:21 +0100 Subject: [PATCH 56/69] =?UTF-8?q?chore:=20resolve=20iss-2609281911024185?= =?UTF-8?q?=20=E2=80=94=20the=20filing-time=20match=20runs=20on=20every=20?= =?UTF-8?q?route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three routes are done: inbox promote and the consistency pass (1029ff5b3), and the reading ingest with the promote of a reading item (b796ac79c), per ruling DQ2b. Resolves: iss-2609281911024185 Assisted-by: Claude:claude-opus-5-5 --- ...609212137116617-s-press-release-says-a-new-issue-is.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md (84%) diff --git a/.abcd/work/issues/open/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md b/.abcd/work/issues/resolved/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md similarity index 84% rename from .abcd/work/issues/open/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md rename to .abcd/work/issues/resolved/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md index ad5f2f1e0..b45362444 100644 --- a/.abcd/work/issues/open/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md +++ b/.abcd/work/issues/resolved/iss-2609281911024185-itd-2609212137116617-s-press-release-says-a-new-issue-is.md @@ -12,6 +12,10 @@ found_at: "internal/core/report/inbox.go" deferred_after: "v0.11.1" deferral_reason: "a lane of its own, or a ruling: routing inbox promote, IngestConsistency and IngestReading through the filing-time match threads the layered match config through the report package and moves the two ingests behind matchAndLink, more than an hour with tests; the alternative, narrowing itd-2609212137116617's press release to the two verbs, is the product thinker's text to change." remedy: "Route every ledger writer through the filing-time match: inbox promote passes the layered match config on CaptureRequest.Match (internal/core/report/inbox.go captureRequest), and IngestConsistency and IngestReading write through matchAndLink (internal/core/capture/workflow.go), proven by one test per writer that files a near-double and finds the duplicates or refines link written." +resolution: "The filing-time match runs on every route that files a record: inbox promote and the consistency pass (1029ff5b3), and the reading ingest plus the promote of a reading item, per ruling DQ2b (adr-2609300821558671)." +impact: additive +resolved_by: + commit: "b796ac79c" --- itd-2609212137116617's press release says a new issue is matched against the record at filing, but only the capture verb and the quoted-text intent create run the filing-time match. The ledger's other writers file without it: inbox promote builds a capture.CaptureRequest with no Match (internal/core/report/inbox.go captureRequest), and IngestConsistency and IngestReading (internal/core/capture/consistency.go, reading.go) write issue records outside matchAndLink (internal/core/capture/workflow.go). The unattended paths the intent's Grounds name as the reason for the match are exactly the ones that skip it, so a double promoted from a peer report or filed by a consistency pass is never linked at filing. Either these writers pass the layered match config through CaptureRequest.Match, or the record narrows its claim to the two verbs. @@ -23,3 +27,7 @@ This keeps itd-2609212137116617's shipped promise instead of narrowing it, and t ## Progress (2026-09-30) Two of the three routes landed on branch feat/filing-duplicate-every-route: inbox promote and the consistency ingest now run the filing-time match through CaptureRequest.Match, on the report's own title and prose and on the finding's summary and explanation. The reading-ingest route waits on ruling DQ2b, so this record stays open. + +## Grounds + +- pursued: a finding filed through any of the three routes that doubles an open record carries a duplicates: or refines: link naming it; a double filed through one of them with no link would show this wrong From 42f050361cfe077c368664132a8ee86b501caf30 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:39:19 +0100 Subject: [PATCH 57/69] fix(bootstrap): announce an update once, when the swap completes The rulings CJ1 and CJ1b: the installer records the release it replaced and says "abcd updated from X to Y" once, when it finishes, and the session check stops reporting the transition. - hooks/bootstrap.sh records previous_tag in the binary-meta it writes at the cache and per-root swaps, and opens its one-line success notice with the update line when the release changed. A first install, a cache copy at the same release and the fast path print no such line. - update.UpdatedFormat is the one wording; `abcd update` opens its receipt with it, and a test holds the script's printf literal to it. - The session-start setup_version comparison (ahoy.VersionTransition) is removed; ahoy's own version.upgrade gap stays. - The single exception: the salvage runs in the UserPromptSubmit, PreToolUse and PreCompact hooks discard the bootstrap's output, so they pass --unseen, the swap records transition_unseen=yes, and the next session start shows the line once and writes cache/update-shown in the data dir (dataDirHazard guard, release-tag shape check), its only write. - itd-111 criterion 6 is amended, with a DECISIONS entry citing CJ1b. Refs: iss-2609291942520919 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/21-update.md | 21 ++- ...nswers-silently-every-surface-that-runs.md | 8 + .abcd/work/DECISIONS.md | 1 + commands/update.md | 5 +- docs/how-to/install.md | 6 + hooks/bootstrap.sh | 43 ++++- hooks/hooks.json | 6 +- internal/core/ahoy/transition_test.go | 43 ----- internal/core/ahoy/unseen_update.go | 61 +++++++ internal/core/ahoy/unseen_update_test.go | 93 ++++++++++ internal/core/ahoy/vintage.go | 41 +---- internal/core/update/update.go | 20 ++- internal/surface/cli/bootstrap_cache_test.go | 9 +- internal/surface/cli/bootstrap_update_test.go | 162 ++++++++++++++++++ internal/surface/cli/cli.go | 21 ++- internal/surface/cli/transition_test.go | 61 ++++--- internal/surface/cli/update.go | 6 +- internal/surface/cli/update_test.go | 9 +- 18 files changed, 490 insertions(+), 126 deletions(-) create mode 100644 internal/core/ahoy/unseen_update.go create mode 100644 internal/core/ahoy/unseen_update_test.go create mode 100644 internal/surface/cli/bootstrap_update_test.go diff --git a/.abcd/development/brief/04-surfaces/21-update.md b/.abcd/development/brief/04-surfaces/21-update.md index 3a9a0c35e..5ff9c2029 100644 --- a/.abcd/development/brief/04-surfaces/21-update.md +++ b/.abcd/development/brief/04-surfaces/21-update.md @@ -103,6 +103,25 @@ as somebody else's binary the next time an install shape is judged. The re-stamp runs on an already-current outcome too, and it does nothing at all where no record names that path. +## An update says so once + +Every swap of the abcd binary announces itself once, when it completes, in one +wording: `abcd updated from <old> to <new>` (`update.UpdatedFormat`, the ruling +CJ1b of 2026-09-29). `abcd update` opens its receipt with that line, and the +plugin bootstrap (`hooks/bootstrap.sh`) opens its success notice with it when +the release it installs replaces an earlier one, and records the replaced +release as `previous_tag` in the `binary-meta` it writes at the swap (the ruling +CJ1). A first install, a copy out of the cache at the same release and the +per-session fast path replace no release and print no such line. The session +start check does not compare versions and shows nothing about an update, with +one exception: the bootstrap salvage that the per-prompt, per-command and +pre-compaction hooks run discards its output, so a swap made there adds +`transition_unseen=yes` to the cache's `binary-meta`, and the next session start +shows the line once and writes the release it showed to `cache/update-shown` in +the plugin data directory. That marker is the session check's single write; a +data directory failing the shape check every reader of it applies, or a tag +outside the release-tag alphabet, shows nothing and writes nothing. + ## The receipt Three terminal outcomes ship, and the receipt's `action` field names which one @@ -110,7 +129,7 @@ happened: | `action` | What it means | |---|---| -| `swapped` | the file was replaced, and the render reads `updated <path>: <old> -> <tag>` | +| `swapped` | the file was replaced, and the render opens `abcd updated from <old> to <tag>`, with the path on the line below | | `already-current` | the target's digest already equals the release's, so the binary is left untouched | | `refused` | a dispatch or ownership refusal, naming its shape and its remedy | diff --git a/.abcd/development/intents/shipped/itd-111-a-stale-abcd-never-answers-silently-every-surface-that-runs.md b/.abcd/development/intents/shipped/itd-111-a-stale-abcd-never-answers-silently-every-surface-that-runs.md index 22fdb2fb4..bce16d05a 100644 --- a/.abcd/development/intents/shipped/itd-111-a-stale-abcd-never-answers-silently-every-surface-that-runs.md +++ b/.abcd/development/intents/shipped/itd-111-a-stale-abcd-never-answers-silently-every-surface-that-runs.md @@ -149,6 +149,14 @@ None stated. - Given provisioning has fetched a new pinned binary (itd-105/108's job), when the next session starts, then the session reports the version transition performed. + _Amended 2026-09-30 by the product thinker's rulings CJ1 and CJ1b of + 2026-09-29 (recorded in `.abcd/work/DECISIONS.md` under 2026-09-30): the + transition is reported once, by the process that performed the swap, when + it completes: the bootstrap's success notice and `abcd update`'s receipt + open with `abcd updated from X to Y`, and the session start shows it only + for a swap made by a hook that discards its output, once. The per-repo + setup_version comparison that first met this criterion is removed + (iss-2609291942520919)._ - Given a binary whose vintage cannot be determined (unstamped build, dirty `vcs.modified` rebuild), when staleness is evaluated, then the state is reported as unknown — never as fresh — and `abcd ahoy install` run through diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 25682bb00..27095efb4 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2600,3 +2600,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-29 — The 2026-09-29 entry applying ruling J15 names one plain-path consequence of the ledger forgetting a stopped domain, a domain edited out of `rules.json` and back; there is a second, which needs no edit at all: a dormant domain activated with `*NAME` is in force for that prompt alone, so a prompt without the prefix drops it from the active set and the next `*NAME` renders it again (review-routerRemoval finding 2; recorded by fix round fix-routerRemoval of autonomous run A; Refs: iss-2608261550580260). Both follow from J15, since the domain left the set in between, and both are documented in brief `05-internals/03-configuration.md` "The prompt router's output"; the rule-loader text in `AGENTS.md` and the managed-repository marker block qualify "never re-injects unchanged rules" to a domain that stays in force. The earlier entry stands as written. - 2026-09-29 — Every new issue carries a remedy, and abcd's own automatic filers write one machine value when they have no fix (the product thinker's rulings BX3 and H12 of 2026-09-29, applied by lane remedyRequired of autonomous run A; partial of itd-82, whose decision 6 already required the field). BX3, verbatim: "REFUSE the filing; every new issue must carry remedy:". H12, verbatim: "'NO FIX YET' ALLOWED: automatic filers may write remedy 'none (filed automatically)'; the record is filed, drain skips it until a person writes a real remedy." As built: `capture` refuses a new issue with no remedy or a blank one, exit 2 and nothing written, naming `--remedy` and the machine value; the value is spelt once, `issueschema.MachineRemedy`, and written by every in-binary filer (the consistency pass, and an inbox report promoted without a remedy of its own, whose own remedy otherwise becomes the issue's); `abcd drain --dry-run` lists a record carrying it as ineligible, naming the automatic filer and the verb that answers it; and `capture remedy <iss-N> "<fix>"` writes or replaces the remedy on an open issue. A record filed before the rule carries none, stays readable and valid, and is listed as ineligible. The one-line capture friction that `commands/capture.md` and the principle `adversarial-review-scales-with-blast-radius` promised is tightened by the ruling, and both now say so. A person typing the machine value is REFUSED, by `capture --remedy` and by `capture remedy`, compared trimmed and case-folded: the value is useful only while it means that a machine filed the record, and a person has either a fix to name or the choice not to file; allowing it would let a hand-filed record pass as machine-filed and be skipped silently. A remedy chosen in an autonomous run cites its grounds, a prior-art or state-of-the-art check where the fix depends on outside practice (principle `prefer-sota`), in the capture's text; nothing checks that mechanically yet. - 2026-09-30 — Pending the person's ruling CL1, a promoted inbox report files the machine value: outside text never becomes a drain-eligible remedy without a person naming it (fix round of lane remedyRequired, autonomous run A, closing the review's trust finding on the entry of 2026-09-29 above). As built: `inbox promote` always writes `remedy: none (filed automatically)` (`issueschema.MachineRemedy`), whatever the report proposes, so `abcd drain --dry-run` lists the issue as ineligible; the sender's proposal stays in the issue's text under "Remedy the reporter proposes:", scrubbed like every other value the report carries, for a person to adopt with `capture remedy`. This narrows the entry above, which filed a report's own remedy as the issue's; the ruling CL1 may widen it again. +- 2026-09-30 — Rulings CJ1 and CJ1b of the product thinker (2026-09-29), applied (lane installerMeta2, autonomous run A; Refs: iss-2609291942520919): an update of the abcd binary is announced once, by whatever swapped it, when the swap completes. CJ1, verbatim: "the INSTALLER (bootstrap.sh) writes the previous release tag into binary-meta when it swaps a binary; the session hook stays read-only. Follow-up: it REPLACES the setup_version comparison." CJ1b, verbatim: "ONCE ONLY. Preferred mechanism (the person's note): couple the notice to the install/update process itself — the installer shows 'updated from X to Y' when it concludes, so it is naturally linked to the installation and the session check need neither show it nor write anything. Only IF that is not possible (e.g. the swap happens where no one sees its output), the session check may write one small 'shown' marker as a single exception to its read-only rule." As built: `hooks/bootstrap.sh` records `previous_tag` in the `binary-meta` it writes at a cache or per-root swap and opens its success notice with `abcd updated from X to Y` (`update.UpdatedFormat`, the one wording, which `abcd update` also opens its receipt with); the session-start setup_version comparison (`ahoy.VersionTransition`) is removed, and ahoy's own `version.upgrade` gap stays. The exception applies: the bootstrap salvage in the UserPromptSubmit, PreToolUse and PreCompact hooks discards its output (`hooks/hooks.json`), so those runs pass `--unseen`, record `transition_unseen=yes`, and the next session start shows the line once and writes `cache/update-shown` in the plugin data directory, its single write. itd-111 criterion 6 is amended to match. diff --git a/commands/update.md b/commands/update.md index 72ba9e062..4d7d21ed3 100644 --- a/commands/update.md +++ b/commands/update.md @@ -10,8 +10,9 @@ block: people Complete a chosen update of the PATH-installed binary. The verb's documented meaning IS the fetch: it resolves the latest release (or takes an explicit tag), verifies the platform binary against the same release's -`checksums.txt`, and swaps the PATH copy atomically, printing a receipt with -the origin, tag, digest, and old→new versions. abcd never checks for or +`checksums.txt`, and swaps the PATH copy atomically, printing a receipt that +opens with `abcd updated from <old> to <new>` and names the path, origin and +digest. abcd never checks for or applies updates on its own — this verb is the only command that reaches the release origin, and only when invoked. diff --git a/docs/how-to/install.md b/docs/how-to/install.md index 6e59f961f..f2354ea04 100644 --- a/docs/how-to/install.md +++ b/docs/how-to/install.md @@ -170,6 +170,12 @@ scrolled away and you would rather not go looking for the directory, the [install](#cli) one-liner below needs no plugin root at all and gets you to the same place. +When a plugin update brings a newer release, the bootstrap's success notice +opens with `abcd updated from <old> to <new>`, once, in the session that +installs it; `abcd update` opens its receipt with the same line. If the new +release was installed by a hook that shows no output, the next session start +prints that line instead, once. + For a stronger root of trust than same-origin checksums, build from source — `go build ./cmd/abcd` — and place the binary in the plugin root and on your `PATH` yourself. A binary placed there by hand takes the same no-network fast diff --git a/hooks/bootstrap.sh b/hooks/bootstrap.sh index e12aef7e0..af0bb3ff8 100755 --- a/hooks/bootstrap.sh +++ b/hooks/bootstrap.sh @@ -15,6 +15,14 @@ set -u +# --unseen is passed by the salvage runs in the per-prompt, per-command and +# pre-compaction hooks (hooks/hooks.json), which discard everything this script +# prints. A release swap made there reports "abcd updated from X to Y" to no one, +# so it says so in the record instead (transition_unseen=yes), and the next +# session start shows the line once (the ruling CJ1b's single exception). +output_unseen='' +[ "${1:-}" = '--unseen' ] && output_unseen=yes + plugin_root="${CLAUDE_PLUGIN_ROOT:-}" [ -n "$plugin_root" ] || exit 0 @@ -577,6 +585,19 @@ if [ -n "$cache_mode" ] && [ -f "$cache_binary" ]; then [ -n "$cached_sha" ] || cached_tag='' fi +# prev_tag is the release this run replaces, read before anything is written: +# the cache's record in cache mode, the root's own record in the per-root mode +# (it outlives a binary removed from beside it). A fresh root with no record +# replaces nothing, and a first install is not an update. The swap records it +# as previous_tag (the ruling CJ1) and, when it differs from the release +# installed, leads the success notice with the update line (CJ1b). +prev_tag='' +if [ -n "$cache_mode" ]; then + [ -f "$cache_binary" ] && prev_tag=$(meta_field "$cache_meta" release_tag) +else + prev_tag=$(meta_field "$plugin_root/.binary-meta" release_tag) +fi + resolved_tag='' if command -v curl >/dev/null 2>&1; then redirect=$(curl -q -fsS --proto '=https' --proto-redir '=https' --max-time 15 -o /dev/null -w '%{redirect_url}' "$releases_url/latest" 2>/dev/null) || redirect='' @@ -656,6 +677,8 @@ path_note='' from_note='' stamp_note='' attest_note='' +update_lead='' +update_record='' if [ -n "$use_cache" ]; then release_tag="$cached_tag" @@ -676,6 +699,20 @@ else refuse "the latest release tag could not be resolved, so the download cannot be pinned to a single release — there may be no network; $ignored_env" release_tag="$resolved_tag" + # The swap below replaces prev_tag's release, so its record names it + # (CJ1), and the success notice leads with the one line the swap owes its + # reader (CJ1b) in the wording update.UpdatedFormat holds: first, because + # only the first line of a hook's stderr reaches the transcript (iss-208). + # A re-download of the same release is no update and says nothing of one. + # A run whose output nobody reads records that too, for the session check. + if [ -n "$prev_tag" ]; then + update_record=$(printf 'previous_tag=%s\n' "$prev_tag") + if [ "$prev_tag" != "$release_tag" ]; then + update_lead="$(printf 'abcd updated from %s to %s' "$prev_tag" "$release_tag"). " + [ -z "$output_unseen" ] || update_record=$(printf '%s\ntransition_unseen=yes' "$update_record") + fi + fi + # 6. Download into the mode's temp dir — the data dir in cache mode (same # filesystem as the cache, so publishing into it is a rename), the plugin # root otherwise (same filesystem as the install, ditto). @@ -744,6 +781,7 @@ else printf 'release_sha=%s\n' "$release_sha" printf 'binary_sha256=%s\n' "$binary_sha256" printf 'fetched_at=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + [ -z "$update_record" ] || printf '%s\n' "$update_record" } > "$tmp/binary-meta" 2>/dev/null chmod 0755 "$tmp/$asset" 2>/dev/null || refuse "the downloaded $asset cannot be made executable" @@ -987,6 +1025,7 @@ else printf 'release_sha=%s\n' "$release_sha" printf 'binary_sha256=%s\n' "$binary_sha256" printf 'fetched_at=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + [ -z "$update_record" ] || printf '%s\n' "$update_record" } > "$tmp/binary-meta" 2>/dev/null && mv -f "$tmp/binary-meta" "$meta_path" 2>/dev/null || meta_note=' (the .binary-meta provenance record could not be written, so version-skew reporting stays silent for this plugin root)' @@ -1037,5 +1076,5 @@ fi # # The path is wrapped in SINGLE quotes (binary_quoted, defined at the top) for # the reason given there: this string is printed to be pasted into a shell. -notice "$(printf 'abcd bootstrap: installed the checksum-verified abcd binary (release %s) into the plugin root, so the abcd hooks are live for this session.%s%s%s%s%s%s%s%s For the abcd command in your own terminal, run this once — the path is absolute because abcd is not on your PATH yet, which is exactly what the command fixes: %s ahoy install' \ - "$release_tag" "$from_note" "$stale_note" "$path_note" "$meta_note" "$cache_note" "$stamp_note" "$attest_note" "$degrade_note" "$binary_quoted")" +notice "$(printf '%sabcd bootstrap: installed the checksum-verified abcd binary (release %s) into the plugin root, so the abcd hooks are live for this session.%s%s%s%s%s%s%s%s For the abcd command in your own terminal, run this once — the path is absolute because abcd is not on your PATH yet, which is exactly what the command fixes: %s ahoy install' \ + "$update_lead" "$release_tag" "$from_note" "$stale_note" "$path_note" "$meta_note" "$cache_note" "$stamp_note" "$attest_note" "$degrade_note" "$binary_quoted")" diff --git a/hooks/hooks.json b/hooks/hooks.json index 84f44637d..95266a80b 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -7,7 +7,7 @@ "type": "command", "timeout": 120, "statusMessage": "abcd: checking the plugin binary; a first run downloads it, so this can take a while", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 </dev/null || true; fi; g=\"\"; [ -x \"$r/abcd\" ] && g=\"$r/abcd\"; if [ -z \"$g\" ]; then c=$(command -v abcd 2>/dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router; fi; printf '%s\\n' \"abcd: the plugin binary is missing and could not be provisioned, so the rules loader is inactive for this prompt — hooks/bootstrap.sh installs it when the session has network access, or install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" --unseen >/dev/null 2>&1 </dev/null || true; fi; g=\"\"; [ -x \"$r/abcd\" ] && g=\"$r/abcd\"; if [ -z \"$g\" ]; then c=$(command -v abcd 2>/dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router; fi; printf '%s\\n' \"abcd: the plugin binary is missing and could not be provisioned, so the rules loader is inactive for this prompt — hooks/bootstrap.sh installs it when the session has network access, or install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } @@ -31,7 +31,7 @@ { "type": "command", "statusMessage": "abcd: checking the plugin binary; a first run downloads it, so this can take a while", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; i=$(cat); case \"$i\" in *'\"tool_name\":\"AskUserQuestion\"'*|*'\"tool_name\": \"AskUserQuestion\"'*) k=\"questions through AskUserQuestion run UNGUARDED\" ;; *) k=\"shell commands run UNGUARDED\" ;; esac; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 </dev/null || true; fi; g=\"\"; [ -x \"$r/abcd\" ] && g=\"$r/abcd\"; if [ -z \"$g\" ]; then c=$(command -v abcd 2>/dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then printf '%s' \"$i\" | \"$g\" guard hook; s=$?; [ $s -eq 0 ] || [ $s -eq 1 ] || [ $s -eq 2 ] || { echo \"abcd guard: FAILED TO RUN (exit $s) — $k in this session; run 'abcd ahoy' to see guard health\" >&2; exit 1; }; exit $s; fi; printf '%s\\n' \"abcd guard: the plugin binary is missing, so $k until it is provisioned — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; i=$(cat); case \"$i\" in *'\"tool_name\":\"AskUserQuestion\"'*|*'\"tool_name\": \"AskUserQuestion\"'*) k=\"questions through AskUserQuestion run UNGUARDED\" ;; *) k=\"shell commands run UNGUARDED\" ;; esac; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" --unseen >/dev/null 2>&1 </dev/null || true; fi; g=\"\"; [ -x \"$r/abcd\" ] && g=\"$r/abcd\"; if [ -z \"$g\" ]; then c=$(command -v abcd 2>/dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then printf '%s' \"$i\" | \"$g\" guard hook; s=$?; [ $s -eq 0 ] || [ $s -eq 1 ] || [ $s -eq 2 ] || { echo \"abcd guard: FAILED TO RUN (exit $s) — $k in this session; run 'abcd ahoy' to see guard health\" >&2; exit 1; }; exit $s; fi; printf '%s\\n' \"abcd guard: the plugin binary is missing, so $k until it is provisioned — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } @@ -42,7 +42,7 @@ { "type": "command", "statusMessage": "abcd: checking the plugin binary; a first run downloads it, so this can take a while", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 </dev/null || true; fi; g=\"\"; [ -x \"$r/abcd\" ] && g=\"$r/abcd\"; if [ -z \"$g\" ]; then c=$(command -v abcd 2>/dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router-reset; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so rules will not re-inject after compaction — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" --unseen >/dev/null 2>&1 </dev/null || true; fi; g=\"\"; [ -x \"$r/abcd\" ] && g=\"$r/abcd\"; if [ -z \"$g\" ]; then c=$(command -v abcd 2>/dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router-reset; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so rules will not re-inject after compaction — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } diff --git a/internal/core/ahoy/transition_test.go b/internal/core/ahoy/transition_test.go index e9a5474e9..f08ea8c54 100644 --- a/internal/core/ahoy/transition_test.go +++ b/internal/core/ahoy/transition_test.go @@ -8,49 +8,6 @@ import ( "github.com/intentdriven/abcd/internal/core" ) -func TestVersionTransitionFrom(t *testing.T) { - cases := []struct { - name string - recorded string - running string - wantChanged bool - }{ - {"a newer running version is a transition", "v1.2.0", "v1.3.0", true}, - {"an older running version is still a transition (direction-neutral)", "v1.3.0", "v1.2.0", true}, - {"same version is no transition", "v1.2.0", "v1.2.0", false}, - {"a dev running version never reports a transition", "v1.2.0", "dev", false}, - {"an unrecorded version never reports a transition", "", "v1.3.0", false}, - // iss-2608241115259170: a setup stamped by a dev build can never - // reconcile against a release, so it is no transition either. - {"a dev recorded version never reports a transition", "dev", "v1.3.0", false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - _, _, changed := versionTransitionFrom(c.recorded, c.running) - if changed != c.wantChanged { - t.Fatalf("changed = %v, want %v", changed, c.wantChanged) - } - }) - } -} - -func TestRecordedSetupVersionReadsConfig(t *testing.T) { - dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, ".abcd"), 0o755); err != nil { - t.Fatal(err) - } - const cfg = `{"meta":{"setup_version":"v1.2.0","schema_version":1}}` + "\n" - if err := os.WriteFile(filepath.Join(dir, ".abcd", "config.json"), []byte(cfg), 0o644); err != nil { - t.Fatal(err) - } - if got := recordedSetupVersion(dir); got != "v1.2.0" { - t.Fatalf("recordedSetupVersion = %q, want v1.2.0", got) - } - if got := recordedSetupVersion(t.TempDir()); got != "" { - t.Fatalf("recordedSetupVersion with no config = %q, want empty", got) - } -} - // TestDetectVersionIgnoresADevSide is the detection half of // iss-2608241115259170: version.upgrade is a required gap, and with either side // a dev build it could never settle. A dev-stamped repo run by a release binary diff --git a/internal/core/ahoy/unseen_update.go b/internal/core/ahoy/unseen_update.go new file mode 100644 index 000000000..e263a5c30 --- /dev/null +++ b/internal/core/ahoy/unseen_update.go @@ -0,0 +1,61 @@ +package ahoy + +import ( + "path/filepath" + "regexp" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// ReleaseTagShape is the accepted release-tag alphabet, one definition for the +// updater (a tag travels into a URL path there) and for the unseen-update +// notice below (a tag travels onto a terminal there). +var ReleaseTagShape = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$`) + +// updateShownFile is the marker the session check writes beside the cache's +// binary-meta: the release whose unseen update it has shown, as one +// release_tag= line. +const updateShownFile = "update-shown" + +// TakeUnseenUpdate reports the one release swap nobody was told about, and +// marks it told. It is the ruling CJ1b's single exception to the session +// check's read-only rule, and the marker it writes is the only write. +// +// An update is announced by whatever performed the swap, when the swap +// completes: `abcd update` prints update.UpdatedFormat, and hooks/bootstrap.sh +// leads its success notice with it. The one swap whose output no one reads is +// the bootstrap salvage the per-prompt, per-command and pre-compaction hooks +// run with their output discarded (hooks/hooks.json); that run records +// transition_unseen=yes beside previous_tag in the cache's binary-meta, and +// this is what shows it, once, at the next session start. A swap whose own +// line was relayed carries no flag, so this shows it never and writes nothing. +// +// The data dir is an environment value, so it passes dataDirHazard before +// anything is read from it or written into it, and both tags must have +// ReleaseTagShape: a record an HTTP redirect filled shows nothing it cannot +// vouch for. The marker is written before the update is reported, and a marker +// that cannot be written reports nothing: once is the ruling, and a notice +// that cannot remember it was shown would repeat every session. +func TakeUnseenUpdate(pluginRoot, cwd string) (from, to string, ok bool) { + data := pluginDataDir(pluginRoot).dir + if data == "" || dataDirHazard(data, cwd) != "" { + return "", "", false + } + cache := filepath.Join(data, "cache") + meta := filepath.Join(cache, "binary-meta") + if metaField(meta, "transition_unseen") != "yes" { + return "", "", false + } + from, to = metaField(meta, "previous_tag"), metaField(meta, "release_tag") + if !ReleaseTagShape.MatchString(from) || !ReleaseTagShape.MatchString(to) || from == to { + return "", "", false + } + marker := filepath.Join(cache, updateShownFile) + if metaField(marker, "release_tag") == to { + return "", "", false + } + if err := fsutil.WriteFileAtomic(marker, []byte("release_tag="+to+"\n"), 0o600); err != nil { + return "", "", false + } + return from, to, true +} diff --git a/internal/core/ahoy/unseen_update_test.go b/internal/core/ahoy/unseen_update_test.go new file mode 100644 index 000000000..79cfca326 --- /dev/null +++ b/internal/core/ahoy/unseen_update_test.go @@ -0,0 +1,93 @@ +package ahoy + +import ( + "os" + "path/filepath" + "testing" +) + +// seedUnseenCache writes the cache record a bootstrap swap leaves behind. +func seedUnseenCache(t *testing.T, meta string) (data string) { + t.Helper() + data = t.TempDir() + cache := filepath.Join(data, "cache") + if err := os.MkdirAll(cache, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cache, "binary-meta"), []byte(meta), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("CLAUDE_PLUGIN_DATA", data) + return data +} + +const unseenMeta = "release_tag=v0.12.0\nrelease_sha=unknown\nbinary_sha256=" + + "0000000000000000000000000000000000000000000000000000000000000000\n" + + "fetched_at=2026-09-30T00:00:00Z\nprevious_tag=v0.11.1\ntransition_unseen=yes\n" + +// TestTakeUnseenUpdateShowsOnce is the ruling CJ1b's single exception: a swap +// made where no one saw its output is shown by the next session start, once, +// and the marker that makes it once is the only thing the session check writes. +func TestTakeUnseenUpdateShowsOnce(t *testing.T) { + data := seedUnseenCache(t, unseenMeta) + cwd := t.TempDir() + + from, to, ok := TakeUnseenUpdate("", cwd) + if !ok || from != "v0.11.1" || to != "v0.12.0" { + t.Fatalf("the first session after an unseen swap must show it; got %q -> %q (%v)", from, to, ok) + } + if got := metaField(filepath.Join(data, "cache", updateShownFile), "release_tag"); got != "v0.12.0" { + t.Errorf("the shown marker must record the release shown; got %q", got) + } + if _, _, ok := TakeUnseenUpdate("", cwd); ok { + t.Errorf("the second session must not show the same update again") + } +} + +// TestTakeUnseenUpdateLeavesASeenSwapAlone: a swap whose own output was +// relayed carries no transition_unseen flag, so the session check neither +// shows it nor writes anything. +func TestTakeUnseenUpdateLeavesASeenSwapAlone(t *testing.T) { + data := seedUnseenCache(t, "release_tag=v0.12.0\nprevious_tag=v0.11.1\n") + if _, _, ok := TakeUnseenUpdate("", t.TempDir()); ok { + t.Errorf("a seen swap must not be shown again at session start") + } + if _, err := os.Stat(filepath.Join(data, "cache", updateShownFile)); !os.IsNotExist(err) { + t.Errorf("the session check must write nothing for a seen swap: %v", err) + } +} + +// TestTakeUnseenUpdateRefusesAHazardousDataDir: the data dir comes from the +// environment, so it passes the same shape check as every other reader of it +// (dataDirHazard) before anything is read from it or written into it. +func TestTakeUnseenUpdateRefusesAHazardousDataDir(t *testing.T) { + data := seedUnseenCache(t, unseenMeta) + if err := os.Chmod(filepath.Join(data, "cache"), 0o777); err != nil { + t.Fatal(err) + } + if _, _, ok := TakeUnseenUpdate("", t.TempDir()); ok { + t.Errorf("a world-writable data dir must not be believed") + } + if _, err := os.Stat(filepath.Join(data, "cache", updateShownFile)); !os.IsNotExist(err) { + t.Errorf("nothing may be written into a hazardous data dir: %v", err) + } +} + +// TestTakeUnseenUpdateRefusesAMisshapenTag: the tags come off a record on +// disk that an HTTP redirect filled, so only a plain release-tag shape is shown +// or written; a control byte (or anything else) shows nothing and writes +// nothing. +func TestTakeUnseenUpdateRefusesAMisshapenTag(t *testing.T) { + for _, meta := range []string{ + "release_tag=v0.12.0\x1b[2J\nprevious_tag=v0.11.1\ntransition_unseen=yes\n", + "release_tag=v0.12.0\nprevious_tag=v0.11.1 run this\ntransition_unseen=yes\n", + } { + data := seedUnseenCache(t, meta) + if from, to, ok := TakeUnseenUpdate("", t.TempDir()); ok { + t.Errorf("a misshapen tag must not be shown; got %q -> %q", from, to) + } + if _, err := os.Stat(filepath.Join(data, "cache", updateShownFile)); !os.IsNotExist(err) { + t.Errorf("nothing may be written for a misshapen tag: %v", err) + } + } +} diff --git a/internal/core/ahoy/vintage.go b/internal/core/ahoy/vintage.go index 090038f9c..f9c6083ab 100644 --- a/internal/core/ahoy/vintage.go +++ b/internal/core/ahoy/vintage.go @@ -195,8 +195,8 @@ func (v VintageStatus) Staleness() string { // Only the checkout-tip comparison is ancestry-guarded, so only it may // claim a direction. A version/pin comparison is string equality — a // binary newer than its pin is the same inequality read the other way — - // so it stays non-directional ("differs from"), the caution skew.go and - // VersionTransition already take. + // so it stays non-directional ("differs from"), the caution skew.go + // already takes. if v.Source == VintageSourceCheckoutTip { return "stale — behind the checkout tip (" + ref + ")" } @@ -210,44 +210,11 @@ func (v VintageStatus) Staleness() string { } } -// VersionTransition reports a version change performed since this repo was last -// set up: the last-recorded setup_version (written into .abcd/config.json by -// install) against the running binary's version. It is AC6's report only — the -// fetch that changed the binary is provisioning's job (itd-105/108), out of this -// intent. changed is false when either side is undeterminable or they match; the -// report is direction-neutral, since a repo set up by a newer binary and now run -// through an older one is the same inequality read backwards. -func VersionTransition(cwd string) (recorded, running string, changed bool) { - return versionTransitionFrom(recordedSetupVersion(cwd), core.Version) -} - -// versionTransitionFrom is the pure comparison, split so the change/no-change -// branches are testable without a fixture config or a re-stamped core.Version. -func versionTransitionFrom(recorded, running string) (from, to string, changed bool) { - // A dev build on either side is no transition: a dev stamp can never - // reconcile against a release, and re-stamping it would flap the tracked - // config between dev and release installs (iss-2608241115259170). - if isDevOrUnknown(running) || isDevOrUnknown(recorded) { - return recorded, running, false - } - return recorded, running, recorded != running -} - // isDevOrUnknown reports a version that cannot take part in a comparison: none -// recorded, or a local dev build's. +// recorded, or a local dev build's (detect's version.upgrade gap, +// iss-2608241115259170). func isDevOrUnknown(v string) bool { return v == "" || v == "dev" } -// recordedSetupVersion reads meta.setup_version from the repo config, or "" when -// it is absent or unreadable. -func recordedSetupVersion(cwd string) string { - cfg, err := readConfig(cwd) - if err != nil || cfg == nil { - return "" - } - v, _ := subMap(cfg, "meta")["setup_version"].(string) - return v -} - // readPinnedTag reads the release tag the bootstrap's provenance record pins, // or "" when no record answers. The root-local .binary-meta wins when present — // it is written only by the degraded per-root fetch, so it describes exactly diff --git a/internal/core/update/update.go b/internal/core/update/update.go index cdf112d2d..49200eeba 100644 --- a/internal/core/update/update.go +++ b/internal/core/update/update.go @@ -49,6 +49,21 @@ const ( ActionRefused Action = "refused" ) +// UpdatedFormat is the one wording of the line every binary swap prints when it +// completes: `abcd update`'s receipt and hooks/bootstrap.sh's success notice +// both lead with it, and the session check uses it for the one swap whose +// output nobody sees (the ruling CJ1b in .abcd/work/DECISIONS.md). The +// bootstrap is POSIX sh and cannot import it, so +// TestBootstrapUpdateLineIsTheSharedWording holds the script's printf literal +// to this constant. +const UpdatedFormat = "abcd updated from %s to %s" + +// UpdatedLine renders UpdatedFormat. The caller sanitises both tags: they are +// read off an HTTP response or a record on disk. +func UpdatedLine(from, to string) string { + return fmt.Sprintf(UpdatedFormat, from, to) +} + // Refusal is a named no: the shape that refused, why, and the remedy. Every // refusal is loud and names its way out (the itd-130 dispatch contract). type Refusal struct { @@ -287,8 +302,9 @@ var scrubbedEnv = []string{ } // tagShape is the accepted release-tag alphabet; a tag travels into a URL -// path, so anything path-shaped refuses before any request is built. -var tagShape = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$`) +// path, so anything path-shaped refuses before any request is built. It is +// ahoy's, which holds the session check's unseen-update tags to the same shape. +var tagShape = ahoy.ReleaseTagShape const ( maxChecksumsBytes = 1 << 20 // 1 MiB: checksums.txt is a few hundred bytes diff --git a/internal/surface/cli/bootstrap_cache_test.go b/internal/surface/cli/bootstrap_cache_test.go index 87e685065..e5e8b9aa8 100644 --- a/internal/surface/cli/bootstrap_cache_test.go +++ b/internal/surface/cli/bootstrap_cache_test.go @@ -11,6 +11,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/intentdriven/abcd/internal/core/update" ) // spc-35: the harness's persistent per-plugin data directory @@ -348,8 +350,11 @@ func TestBootstrapNewReleaseLeavesForeignPathFileAlone(t *testing.T) { // shell is known to resolve (iss-207). func assertPathRefreshRefusedLoudly(t *testing.T, out, home, pathCopy, reason string) { t.Helper() - if got := firstLine(out); !strings.HasPrefix(got, "abcd bootstrap: installed") { - t.Errorf("the success must still lead the first visible line; first line = %q", got) + // A new release leads with the update line it swapped in (CJ1b); the + // install success follows it on the same line. + got := strings.TrimPrefix(firstLine(out), update.UpdatedLine("v9.9.8", bootstrapTag)+". ") + if !strings.HasPrefix(got, "abcd bootstrap: installed") { + t.Errorf("the success must still lead the first visible line; first line = %q", firstLine(out)) } if strings.Count(strings.TrimSpace(out), "\n") != 0 { t.Errorf("the notice must stay one line — only the first line of a hook's stderr reaches the transcript; output %q", out) diff --git a/internal/surface/cli/bootstrap_update_test.go b/internal/surface/cli/bootstrap_update_test.go new file mode 100644 index 000000000..b3e894030 --- /dev/null +++ b/internal/surface/cli/bootstrap_update_test.go @@ -0,0 +1,162 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/update" +) + +// The ruling CJ1 has the installer record the release it replaced, in the +// binary-meta it writes at the swap, and CJ1b has it say "abcd updated from X +// to Y" ONCE, when the swap completes, so the session check needs to neither +// show it nor write anything (iss-2609291942520919). + +// firstLine (hooks_sessionstart_test.go) is the one line of a hook's stderr the +// harness relays (iss-208), which is why the update statement must sit there. + +// TestBootstrapNewReleaseRecordsAndReportsTheUpdateOnce: a cache swap from an +// older release records that release as previous_tag and leads its notice with +// the shared update line; the next root, served from the cache, and the fast +// path that follows say nothing about an update. +func TestBootstrapNewReleaseRecordsAndReportsTheUpdateOnce(t *testing.T) { + data := t.TempDir() + old := []byte("#!/bin/sh\n# old release\nexit 0\n") + fresh := []byte("#!/bin/sh\n# new release\nexit 0\n") + seedBootstrapCache(t, data, "v9.9.8", old) + fx := bootstrapServer(t, fresh, bootstrapManifest(fresh)) + want := update.UpdatedLine("v9.9.8", bootstrapTag) + + root := bootstrapRoot(t) + out, code := runBootstrapWithData(t, root, data, fx, "") + if code != 0 { + t.Fatalf("a new release must install, got %d (output %q)", code, out) + } + if !strings.HasPrefix(firstLine(out), want) { + t.Errorf("the swap's first output line must lead with %q; got %q", want, firstLine(out)) + } + if n := strings.Count(out, want); n != 1 { + t.Errorf("the update line must be printed exactly once, got %d in %q", n, out) + } + meta := cacheMetaValues(t, data) + if meta["previous_tag"] != "v9.9.8" { + t.Errorf("the cache meta must record the replaced release as previous_tag; got %v", meta) + } + if _, ok := meta["transition_unseen"]; ok { + t.Errorf("a run whose output is relayed must not flag the transition as unseen; got %v", meta) + } + + // The fast path of the same root: nothing at all. + if out, _ := runBootstrapWithData(t, root, data, fx, ""); strings.Contains(out, "updated from") { + t.Errorf("the fast path must not repeat the update line; got %q", out) + } + // A second root served from the now-current cache: no swap, no line. + out, code = runBootstrapWithData(t, bootstrapRootNamed(t, strings.Repeat("a", 40)), data, fx, "") + if code != 0 { + t.Fatalf("a cache hit must install, got %d (output %q)", code, out) + } + if strings.Contains(out, "updated from") { + t.Errorf("a cache hit swaps no release and must not report an update; got %q", out) + } +} + +// TestBootstrapUnseenSwapFlagsTheTransition: the salvage runs in the +// per-prompt, per-command and pre-compaction hooks discard this script's output +// (hooks/hooks.json passes --unseen there), so a swap made there says so in the +// record, which is what lets the next session start show it once. +func TestBootstrapUnseenSwapFlagsTheTransition(t *testing.T) { + data := t.TempDir() + old := []byte("#!/bin/sh\n# old release\nexit 0\n") + fresh := []byte("#!/bin/sh\n# new release\nexit 0\n") + seedBootstrapCache(t, data, "v9.9.8", old) + fx := bootstrapServer(t, fresh, bootstrapManifest(fresh)) + bootstrapRequires(t) + script := bootstrapFixtureScript(t, fx.base) + wrapper := filepath.Join(t.TempDir(), "unseen.sh") + if err := os.WriteFile(wrapper, []byte("#!/bin/sh\nexec '"+script+"' --unseen\n"), 0o755); err != nil { + t.Fatal(err) + } + out, code := runScript(t, wrapper, bootstrapRoot(t), append(fx.env(), "CLAUDE_PLUGIN_DATA="+data), "") + if code != 0 { + t.Fatalf("a new release must install, got %d (output %q)", code, out) + } + meta := cacheMetaValues(t, data) + if meta["previous_tag"] != "v9.9.8" || meta["transition_unseen"] != "yes" { + t.Errorf("an unseen swap must record previous_tag and transition_unseen=yes; got %v", meta) + } +} + +// TestBootstrapDegradedSwapReportsTheReplacedRelease: without a data dir the +// per-root fetch knows the replaced release only from the root's own record, +// which survives when the binary beside it was removed. +func TestBootstrapDegradedSwapReportsTheReplacedRelease(t *testing.T) { + root := bootstrapRoot(t) + prior := "release_tag=v9.9.8\nrelease_sha=" + bootstrapRelease + "\nbinary_sha256=" + strings.Repeat("0", 64) + "\nfetched_at=2026-08-01T00:00:00Z\n" + if err := os.WriteFile(filepath.Join(root, ".binary-meta"), []byte(prior), 0o644); err != nil { + t.Fatal(err) + } + body := []byte("#!/bin/sh\nexit 0\n") + fx := bootstrapServer(t, body, bootstrapManifest(body)) + out, code := runBootstrap(t, root, fx, "") + if code != 0 { + t.Fatalf("the degraded install must succeed, got %d (output %q)", code, out) + } + if want := update.UpdatedLine("v9.9.8", bootstrapTag); !strings.HasPrefix(firstLine(out), want) { + t.Errorf("the degraded swap must lead with %q; got %q", want, firstLine(out)) + } + if got := metaValues(t, root)["previous_tag"]; got != "v9.9.8" { + t.Errorf("the root meta must record previous_tag=v9.9.8; got %q", got) + } +} + +// TestBootstrapFirstInstallReportsNoUpdate: a fresh root with no record of an +// earlier release is an install, not an update, and says nothing of one. +func TestBootstrapFirstInstallReportsNoUpdate(t *testing.T) { + root := bootstrapRoot(t) + body := []byte("#!/bin/sh\nexit 0\n") + fx := bootstrapServer(t, body, bootstrapManifest(body)) + out, code := runBootstrapWithData(t, root, t.TempDir(), fx, "") + if code != 0 { + t.Fatalf("a first install must succeed, got %d (output %q)", code, out) + } + if strings.Contains(out, "updated from") { + t.Errorf("a first install must not report an update; got %q", out) + } +} + +// TestBootstrapUpdateLineIsTheSharedWording: one wording, one place. The script +// cannot import update.UpdatedFormat, so its printf literal is held to it. +func TestBootstrapUpdateLineIsTheSharedWording(t *testing.T) { + body := mustReadFile(t, bootstrapScript(t)) + if want := "printf '" + update.UpdatedFormat + "'"; strings.Count(body, want) != 1 { + t.Errorf("hooks/bootstrap.sh must carry the update line exactly once as %s", want) + } +} + +// TestDiscardedSalvageRunsPassUnseen: every hook entry that runs the bootstrap +// with its output thrown away tells it so, and the one entry that relays the +// output (SessionStart) does not, so the update line reaches a reader exactly +// once whichever entry performed the swap. +func TestDiscardedSalvageRunsPassUnseen(t *testing.T) { + doc := decodedHooksManifest(t) + for event, entries := range doc.Hooks { + for _, entry := range entries { + for _, h := range entry.Hooks { + if !strings.Contains(h.Command, "hooks/bootstrap.sh") { + continue + } + discarded := strings.Contains(h.Command, `/hooks/bootstrap.sh" >/dev/null 2>&1`) || + strings.Contains(h.Command, `/hooks/bootstrap.sh" --unseen >/dev/null 2>&1`) + unseen := strings.Contains(h.Command, `bootstrap.sh" --unseen`) + switch { + case event == "SessionStart" && unseen: + t.Errorf("SessionStart relays the bootstrap's stderr and must not pass --unseen") + case event != "SessionStart" && discarded && !unseen: + t.Errorf("%s discards the bootstrap's output and must pass --unseen", event) + } + } + } + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 4d921ff2b..dcf0b11ea 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -1796,14 +1796,19 @@ an error included, exits 0, so the hook can never wedge a session.`, notices = append(notices, n) } } - // itd-111 (AC6): a version transition performed since this repo was - // last set up — the running binary differs from the recorded - // setup_version. Report only; the fetch that changed it is - // provisioning's job. Both values come from disk (config + build info). - if from, to, changed := ahoy.VersionTransition(cwd); changed { - notices = append(notices, fmt.Sprintf( - "abcd: the running binary is version %s, but this repo was last set up with %s — run `/abcd:ahoy install` (or `abcd ahoy install`) to reconcile the recorded version.", - termsafe.Sanitize(to), termsafe.Sanitize(from))) + // itd-111 (AC6): an update is announced once, by whatever swapped + // the binary, when the swap completes (the ruling CJ1b), so session + // start shows nothing about it — except the one swap whose output + // no one read: the bootstrap salvage the per-prompt, per-command and + // pre-compaction hooks run with their output discarded. That one is + // shown here once, and its marker is this hook's single write. + pluginRoot := os.Getenv("ABCD_PLUGIN_ROOT") + if pluginRoot == "" { + pluginRoot = os.Getenv("CLAUDE_PLUGIN_ROOT") + } + if from, to, ok := ahoy.TakeUnseenUpdate(pluginRoot, cwd); ok { + notices = append(notices, update.UpdatedLine(termsafe.Sanitize(from), termsafe.Sanitize(to))+ + " — the update ran while a hook discarded its output, so it is reported here, once.") } // The inbox greeting (itd-2609221656361680): one line saying how // many reports wait and from how many repositories, and nothing diff --git a/internal/surface/cli/transition_test.go b/internal/surface/cli/transition_test.go index 15f1e3e4e..3d990ae15 100644 --- a/internal/surface/cli/transition_test.go +++ b/internal/surface/cli/transition_test.go @@ -7,15 +7,28 @@ import ( "testing" "github.com/intentdriven/abcd/internal/core" + "github.com/intentdriven/abcd/internal/core/update" ) -// TestSessionStartReportsVersionTransition proves AC6: when the running binary's -// version differs from the version recorded when the repo was last set up, the -// session-start hook reports the transition. The comparison is disk-only. -func TestSessionStartReportsVersionTransition(t *testing.T) { +// sessionStartSandbox gives a session-start run its own HOME, no plugin root +// and the given data dir, so nothing a run writes lands outside the test. +func sessionStartSandbox(t *testing.T, data string) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("ABCD_PLUGIN_ROOT", "") + t.Setenv("CLAUDE_PLUGIN_ROOT", "") + t.Setenv("CLAUDE_PLUGIN_DATA", data) +} + +// TestSessionStartDoesNotCompareSetupVersion: the ruling CJ1 replaces the +// setup_version comparison. An update is announced once, by whatever swapped +// the binary, so a repo whose recorded setup_version differs from the running +// binary gets no transition notice at session start. +func TestSessionStartDoesNotCompareSetupVersion(t *testing.T) { orig := core.Version core.Version = "v9.9.9" t.Cleanup(func() { core.Version = orig }) + sessionStartSandbox(t, "") repo := t.TempDir() if err := os.MkdirAll(filepath.Join(repo, ".abcd"), 0o755); err != nil { @@ -25,34 +38,36 @@ func TestSessionStartReportsVersionTransition(t *testing.T) { []byte(`{"meta":{"setup_version":"v1.0.0"}}`+"\n"), 0o644); err != nil { t.Fatal(err) } - - // The hook exits non-zero when it emits notices (so SessionStart shows them); - // the notice text is on the captured stream regardless. out, _ := runCLIStdinErr(t, `{"cwd":"`+repo+`"}`, "hook", "session-start") - s := string(out) - if !strings.Contains(s, "v9.9.9") || !strings.Contains(s, "v1.0.0") { - t.Fatalf("transition notice missing running/recorded versions:\n%s", s) + if s := string(out); strings.Contains(s, "last set up with") || strings.Contains(s, "v1.0.0") { + t.Fatalf("session start must not report a setup_version transition:\n%s", s) } } -// TestSessionStartSilentWhenVersionMatches proves the report does not fire when -// the recorded and running versions agree. -func TestSessionStartSilentWhenVersionMatches(t *testing.T) { - orig := core.Version - core.Version = "v9.9.9" - t.Cleanup(func() { core.Version = orig }) - - repo := t.TempDir() - if err := os.MkdirAll(filepath.Join(repo, ".abcd"), 0o755); err != nil { +// TestSessionStartShowsAnUnseenUpdateOnce is the ruling CJ1b's single +// exception, end to end through the hook: a swap whose own output was +// discarded is shown by the next session start in the shared wording, and not +// by the one after it. +func TestSessionStartShowsAnUnseenUpdateOnce(t *testing.T) { + data := t.TempDir() + cache := filepath.Join(data, "cache") + if err := os.MkdirAll(cache, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(repo, ".abcd", "config.json"), - []byte(`{"meta":{"setup_version":"v9.9.9"}}`+"\n"), 0o644); err != nil { + meta := "release_tag=v0.12.0\nrelease_sha=unknown\nfetched_at=2026-09-30T00:00:00Z\nprevious_tag=v0.11.1\ntransition_unseen=yes\n" + if err := os.WriteFile(filepath.Join(cache, "binary-meta"), []byte(meta), 0o644); err != nil { t.Fatal(err) } + sessionStartSandbox(t, data) + repo := t.TempDir() + want := update.UpdatedLine("v0.11.1", "v0.12.0") out, _ := runCLIStdinErr(t, `{"cwd":"`+repo+`"}`, "hook", "session-start") - if strings.Contains(string(out), "last set up with") { - t.Fatalf("transition reported when versions match:\n%s", out) + if n := strings.Count(string(out), want); n != 1 { + t.Fatalf("the first session after an unseen swap must show %q once, got %d:\n%s", want, n, out) + } + out, _ = runCLIStdinErr(t, `{"cwd":"`+repo+`"}`, "hook", "session-start") + if strings.Contains(string(out), "updated from") { + t.Fatalf("the next session must not show the update again:\n%s", out) } } diff --git a/internal/surface/cli/update.go b/internal/surface/cli/update.go index 773a364fb..e2805650c 100644 --- a/internal/surface/cli/update.go +++ b/internal/surface/cli/update.go @@ -184,7 +184,11 @@ func renderUpdateReport(w io.Writer, asJSON bool, rep update.Report) { if rep.OldVersion == "" { old = "an unpublished build" } - fmt.Fprintf(w, "updated %s: %s -> %s\n", termsafe.Sanitize(rep.TargetPath), old, termsafe.Sanitize(rep.NewVersion)) + // The first line is the one wording every swap prints when it + // completes (update.UpdatedFormat, the ruling CJ1b); the bootstrap's + // success notice leads with the same line. + fmt.Fprintln(w, update.UpdatedLine(old, termsafe.Sanitize(rep.NewVersion))) + fmt.Fprintf(w, " path: %s\n", termsafe.Sanitize(rep.TargetPath)) fmt.Fprintf(w, " origin: %s\n", rep.Origin) if rep.OldDigest != "" { fmt.Fprintf(w, " replaced: sha256 %s — in no published release; %s\n", termsafe.Sanitize(rep.OldDigest), rep.Ownership.Prose()) diff --git a/internal/surface/cli/update_test.go b/internal/surface/cli/update_test.go index b156540d7..99d38247d 100644 --- a/internal/surface/cli/update_test.go +++ b/internal/surface/cli/update_test.go @@ -196,8 +196,13 @@ func TestUpdateReceiptKeepsTheOrdinaryVersionLine(t *testing.T) { Digest: strings.Repeat("cd", 32), }) got := out.String() - if !strings.Contains(got, "v0.6.9 -> v0.7.0") { - t.Errorf("the ordinary receipt line changed:\n%s", got) + // The swap's first line is the one wording every swap shares (CJ1b): + // the bootstrap's success notice leads with the same line. + if first, _, _ := strings.Cut(got, "\n"); first != update.UpdatedLine("v0.6.9", "v0.7.0") { + t.Errorf("the receipt must open with %q; got %q", update.UpdatedLine("v0.6.9", "v0.7.0"), first) + } + if !strings.Contains(got, "~/.local/bin/abcd") { + t.Errorf("the receipt must still name the path it swapped:\n%s", got) } if strings.Contains(got, "unpublished") { t.Errorf("a provable old build must not be reported as unpublished:\n%s", got) From b25ba21496f2106794904889c3ece9e9879aeef9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:39:38 +0100 Subject: [PATCH 58/69] =?UTF-8?q?chore:=20resolve=20iss-2609291942520919?= =?UTF-8?q?=20=E2=80=94=20the=20update=20is=20announced=20once,=20at=20the?= =?UTF-8?q?=20swap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolved with the rulings CJ1 and CJ1b applied in the previous commit; its deferral past v0.11.1 is dropped with it. Resolves: iss-2609291942520919 Assisted-by: Claude:claude-opus-5-5 --- ...eptance-criterion-6-after-provisioning-fetched-a.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) rename .abcd/work/issues/{open => resolved}/iss-2609291942520919-itd-111-acceptance-criterion-6-after-provisioning-fetched-a.md (65%) diff --git a/.abcd/work/issues/open/iss-2609291942520919-itd-111-acceptance-criterion-6-after-provisioning-fetched-a.md b/.abcd/work/issues/resolved/iss-2609291942520919-itd-111-acceptance-criterion-6-after-provisioning-fetched-a.md similarity index 65% rename from .abcd/work/issues/open/iss-2609291942520919-itd-111-acceptance-criterion-6-after-provisioning-fetched-a.md rename to .abcd/work/issues/resolved/iss-2609291942520919-itd-111-acceptance-criterion-6-after-provisioning-fetched-a.md index f76114c54..ac2fa329a 100644 --- a/.abcd/work/issues/open/iss-2609291942520919-itd-111-acceptance-criterion-6-after-provisioning-fetched-a.md +++ b/.abcd/work/issues/resolved/iss-2609291942520919-itd-111-acceptance-criterion-6-after-provisioning-fetched-a.md @@ -8,8 +8,10 @@ source: "review-followup" found_during: "autonomous run A resumed 2026-09-25: fidelity audit itd-111" origin: researcher-authored production_mode: hand-written -deferred_after: "v0.11.1" -deferral_reason: "Honouring the plugin-cache reference spc-22 names needs a design choice the record does not settle, so nothing is built yet. Three questions are open: which process records the last-reported version (the session-start hook, which itd-111 design decision 1 says never writes, or the bootstrap that fetches the binary); where the record lives when the plugin data directory comes from the environment, which the cache attestation (GHSA-4q78-ccfv-f374) treats as untrusted; and whether it replaces or sits beside the per-repo setup_version comparison that ships today. Owed: a ruling on who writes the record and where." +resolution: "The installer records the replaced release as previous_tag in binary-meta at each swap (CJ1) and opens its success notice with 'abcd updated from X to Y' once, when the swap completes (CJ1b); abcd update opens its receipt with the same shared line. The session-start setup_version comparison is removed. The one swap nobody sees (the hook salvage runs with discarded output) is shown once at the next session start behind a single 'update-shown' marker in the data dir. itd-111 criterion 6 is amended to match." +impact: fix +resolved_by: + commit: "42f050361" --- itd-111 acceptance criterion 6 (after provisioning fetched a new pinned binary, the next session reports the version transition performed) is delivered as a per-repo comparison: ahoy.VersionTransition (internal/core/ahoy/vintage.go:220-234) compares core.Version against meta.setup_version in the repo's .abcd/config.json, written by ahoy install, and reports no transition when either side is a dev build. spc-22 stated the reference would be recorded beside the plugin-cache metadata. So a repo never set up by ahoy install, or a dogfood dev build, sees no transition report after provisioning fetched a new binary, which is the scenario the criterion describes. Either the plugin-cache record the spec named is the reference to consult, or the narrowing is recorded on the criterion. @@ -17,3 +19,7 @@ itd-111 acceptance criterion 6 (after provisioning fetched a new pinned binary, ## Deferral 2026-09-29 Deferred past v0.11.1: Honouring the plugin-cache reference spc-22 names needs a design choice the record does not settle, so nothing is built yet. Three questions are open: which process records the last-reported version (the session-start hook, which itd-111 design decision 1 says never writes, or the bootstrap that fetches the binary); where the record lives when the plugin data directory comes from the environment, which the cache attestation (GHSA-4q78-ccfv-f374) treats as untrusted; and whether it replaces or sits beside the per-repo setup_version comparison that ships today. Owed: a ruling on who writes the record and where. + +## Grounds + +- pursued: after a release swap exactly one reader-visible line names the old and new release (the bootstrap notice's first line, the update receipt's first line, or, for a discarded-output salvage, the next session start) and no later session repeats it; a second session printing the line, or a swap that records no previous_tag, would show it wrong From 205b2ada9aaa925cfc163aaa951a6e45c5a39df7 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:51:17 +0100 Subject: [PATCH 59/69] feat(lint): resolve a reading item named by a typed link A reading record's duplicates: and refines: may name an earlier reading item (ruling DQ2b, adr-2609300821558671). record_schema's cross-reference check read only adr, itd, iss and spc handles, so a link naming an rdi-N the ledger does not hold passed unread. The handle pattern now covers rdi, so the gate resolves every id such a link names, as the ADR says. The gate's output over this repository is unchanged. Refs: iss-2609281911024185 Assisted-by: Claude:claude-opus-5-5 --- internal/core/lint/reading_links_test.go | 42 ++++++++++++++++++++++++ internal/core/lint/schema.go | 6 ++-- 2 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 internal/core/lint/reading_links_test.go diff --git a/internal/core/lint/reading_links_test.go b/internal/core/lint/reading_links_test.go new file mode 100644 index 000000000..9ec13ffe2 --- /dev/null +++ b/internal/core/lint/reading_links_test.go @@ -0,0 +1,42 @@ +package lint + +import ( + "path/filepath" + "testing" +) + +// readingLinkRecord is a well-formed detection reading record with the extra +// frontmatter lines given. +func readingLinkRecord(id, extra string) string { + return "---\nschema_version: 1\nid: " + id + "\nrun: rdg-1\nmanifest: sha256:beef\nposition: detection\n" + + "regime: registrative\npattern: a stated constraint\ntension: t\nconstraint_in_play: c\nwhy_a_tension: w\n" + + extra + "---\n\n" +} + +// A reading record carries the filing-time match's typed links (ruling DQ2b, +// adr-2609300821558671). The committed-tree gate accepts them, and resolves +// each id it names: a link to a reading item the ledger holds is clean, and a +// link to one it does not is a finding, as it is on an issue. +func TestReadingRecordTypedLinksAreAcceptedAndResolved(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "rec/.keep", "") + writeFile(t, root, "work/issues/readings/rdg-1/rdi-2.md", readingLinkRecord("rdi-2", "")) + writeFile(t, root, "work/issues/readings/rdg-1/rdi-3.md", readingLinkRecord("rdi-3", "duplicates: [rdi-2]\nrefines: [rdi-2]\n")) + + fs, err := Lint(readingSchemaConfig(), root) + if err != nil { + t.Fatal(err) + } + if n := countRule(fs, ruleRecordSchema); n != 0 { + t.Fatalf("a reading record linking an item the ledger holds must be clean, got %d finding(s): %+v", n, fs) + } + + writeFile(t, root, "work/issues/readings/rdg-1/rdi-4.md", readingLinkRecord("rdi-4", "duplicates: [rdi-9]\n")) + fs, err = Lint(readingSchemaConfig(), root) + if err != nil { + t.Fatal(err) + } + if !findingWith(fs, filepath.Join("work", "issues", "readings", "rdg-1", "rdi-4.md"), ruleRecordSchema, "rdi-9") { + t.Fatalf("a link to a reading item the ledger does not hold must be reported: %+v", fs) + } +} diff --git a/internal/core/lint/schema.go b/internal/core/lint/schema.go index 472956dde..58bfabe13 100644 --- a/internal/core/lint/schema.go +++ b/internal/core/lint/schema.go @@ -49,8 +49,10 @@ var ( // and bare spellings of one id are the same handle. The alternation covers // every store the rule INDEXES — a prefix indexed but not matched here reads as // "no handle at all", which turns a well-formed link into a false blocker and - // leaves its reverse direction unchecked. - recordHandleRe = regexp.MustCompile(`(?i)\b(adr|itd|iss|spc)-(\d+)\b`) + // leaves its reverse direction unchecked. `rdi` is here because a reading + // record's `duplicates:` and `refines:` may name an earlier reading item + // (ruling DQ2b, adr-2609300821558671), and that link must resolve too. + recordHandleRe = regexp.MustCompile(`(?i)\b(adr|itd|iss|spc|rdi)-(\d+)\b`) // The same handle, anchored: a whole frontmatter id value and nothing else. recordHandleFullRe = regexp.MustCompile(`(?i)^(adr|itd|iss|spc)-(\d+)$`) // A frontmatter id of ANY store, parsed by shape rather than against the From 4fb3575b893c3decf55e29580bc5e8ef78f794ee Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:40:08 +0100 Subject: [PATCH 60/69] fix(ahoy): claim an unseen update once per release, per-root included The session check read its shown marker and then wrote it with a create-temp-and-rename, so sessions starting together each saw no marker and each showed the update line (16 of 16 in a probe). Once is now one exclusive create per release: cache/update-shown-<tag> in the plugin data directory, opened O_WRONLY|O_CREATE|O_EXCL at 0600 after the dataDirHazard and ReleaseTagShape checks. An existing claim path (a link included) or any create error shows nothing, so the line never repeats. Claims for earlier releases are left in place: removing one would be a second write, and each is an empty file. With no data directory the bootstrap runs per-root and records the swap in the plugin root's .binary-meta; the check now reads that record and claims .update-shown-<tag> in the root with the same function and the same single write, behind the same shape check, so a per-root unseen swap is shown once rather than never. The brief's update chapter and commands/update.md name both claim files. Refs: iss-2609300939590291 Refs: iss-2609291942520919 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/21-update.md | 17 ++-- ...ks-under-concurrent-session-starts-ahoy.md | 15 ++++ commands/update.md | 6 ++ internal/core/ahoy/unseen_update.go | 55 +++++++----- internal/core/ahoy/unseen_update_test.go | 83 +++++++++++++++++-- 5 files changed, 145 insertions(+), 31 deletions(-) create mode 100644 .abcd/work/issues/open/iss-2609300939590291-once-only-breaks-under-concurrent-session-starts-ahoy.md diff --git a/.abcd/development/brief/04-surfaces/21-update.md b/.abcd/development/brief/04-surfaces/21-update.md index 5ff9c2029..8d6f292f0 100644 --- a/.abcd/development/brief/04-surfaces/21-update.md +++ b/.abcd/development/brief/04-surfaces/21-update.md @@ -116,11 +116,18 @@ per-session fast path replace no release and print no such line. The session start check does not compare versions and shows nothing about an update, with one exception: the bootstrap salvage that the per-prompt, per-command and pre-compaction hooks run discards its output, so a swap made there adds -`transition_unseen=yes` to the cache's `binary-meta`, and the next session start -shows the line once and writes the release it showed to `cache/update-shown` in -the plugin data directory. That marker is the session check's single write; a -data directory failing the shape check every reader of it applies, or a tag -outside the release-tag alphabet, shows nothing and writes nothing. +`transition_unseen=yes` to the `binary-meta` it writes, and the next session +start shows the line once. Once is a claim per release: the session check +creates `cache/update-shown-<new>` in the plugin data directory, or +`.update-shown-<new>` in the plugin root when the bootstrap ran in its degraded +per-root mode and wrote the root's `.binary-meta`, with one exclusive create +(`O_CREATE|O_EXCL`). Of any number of sessions starting together, the one whose +create succeeds shows the line; a claim path that already exists, or a create +that fails for any other reason, shows nothing, so the line never repeats. That +empty claim file is the session check's single write, and claims for earlier +releases stay where they are. A directory failing the shape check every reader +of the data directory applies, or a tag outside the release-tag alphabet, shows +nothing and writes nothing. ## The receipt diff --git a/.abcd/work/issues/open/iss-2609300939590291-once-only-breaks-under-concurrent-session-starts-ahoy.md b/.abcd/work/issues/open/iss-2609300939590291-once-only-breaks-under-concurrent-session-starts-ahoy.md new file mode 100644 index 000000000..2434bedaa --- /dev/null +++ b/.abcd/work/issues/open/iss-2609300939590291-once-only-breaks-under-concurrent-session-starts-ahoy.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609300939590291" +slug: "once-only-breaks-under-concurrent-session-starts-ahoy" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/unseen_update.go" +remedy: "Claim each release with one exclusive create, os.OpenFile(cache/update-shown-<to>, O_WRONLY|O_CREATE|O_EXCL, 0o600), after the dataDirHazard and ReleaseTagShape checks; an existing path or any create error shows nothing (fail closed). O_EXCL is atomic per POSIX open(2), so exactly one concurrent caller wins; a 16-goroutine test holds it." +--- + +Once-only breaks under concurrent session starts: ahoy.TakeUnseenUpdate read the cache/update-shown marker, then wrote it with a create-temp-and-rename, a read-then-write with no claim, so N sessions starting together in one plugin root (an autonomous run does this within a second) each saw no marker and each showed 'abcd updated from X to Y'; a 16-goroutine probe on one seeded cache showed the line 16 times, breaking the ruling CJ1b's once-only. diff --git a/commands/update.md b/commands/update.md index 4d7d21ed3..eb42f4f46 100644 --- a/commands/update.md +++ b/commands/update.md @@ -16,6 +16,12 @@ digest. abcd never checks for or applies updates on its own — this verb is the only command that reaches the release origin, and only when invoked. +The same line announces a swap the plugin bootstrap makes. When a hook that +discards its output made the swap, the next session start shows the line once, +and claims that release with an empty file: `update-shown-<new>` in the plugin +data directory's `cache/`, or `.update-shown-<new>` in the plugin root when there +is no data directory. That claim is the only thing the session start writes. + **Only asking.** When the user wants to know whether a newer release exists without taking it, run the check, which fetches the latest release's tag once and swaps nothing: diff --git a/internal/core/ahoy/unseen_update.go b/internal/core/ahoy/unseen_update.go index e263a5c30..8b466b56e 100644 --- a/internal/core/ahoy/unseen_update.go +++ b/internal/core/ahoy/unseen_update.go @@ -1,10 +1,9 @@ package ahoy import ( + "os" "path/filepath" "regexp" - - "github.com/intentdriven/abcd/internal/fsutil" ) // ReleaseTagShape is the accepted release-tag alphabet, one definition for the @@ -12,37 +11,51 @@ import ( // notice below (a tag travels onto a terminal there). var ReleaseTagShape = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$`) -// updateShownFile is the marker the session check writes beside the cache's -// binary-meta: the release whose unseen update it has shown, as one -// release_tag= line. -const updateShownFile = "update-shown" +// updateShownPrefix names the claim the session check takes for one release's +// unseen update: update-shown-<tag> beside the cache's binary-meta, or +// .update-shown-<tag> beside a plugin root's own .binary-meta in the degraded +// per-root mode. The tag has ReleaseTagShape before it is joined, so it can +// carry no separator and cannot begin with a dot. +const updateShownPrefix = "update-shown-" // TakeUnseenUpdate reports the one release swap nobody was told about, and // marks it told. It is the ruling CJ1b's single exception to the session -// check's read-only rule, and the marker it writes is the only write. +// check's read-only rule, and the claim it creates is the only write. // // An update is announced by whatever performed the swap, when the swap // completes: `abcd update` prints update.UpdatedFormat, and hooks/bootstrap.sh // leads its success notice with it. The one swap whose output no one reads is // the bootstrap salvage the per-prompt, per-command and pre-compaction hooks // run with their output discarded (hooks/hooks.json); that run records -// transition_unseen=yes beside previous_tag in the cache's binary-meta, and +// transition_unseen=yes beside previous_tag in the binary-meta it writes (the +// cache's, or the plugin root's .binary-meta when there is no data dir), and // this is what shows it, once, at the next session start. A swap whose own // line was relayed carries no flag, so this shows it never and writes nothing. // -// The data dir is an environment value, so it passes dataDirHazard before -// anything is read from it or written into it, and both tags must have -// ReleaseTagShape: a record an HTTP redirect filled shows nothing it cannot -// vouch for. The marker is written before the update is reported, and a marker -// that cannot be written reports nothing: once is the ruling, and a notice -// that cannot remember it was shown would repeat every session. +// The directory the record comes from is an environment value, so it passes +// dataDirHazard before anything is read from it or written into it, and both +// tags must have ReleaseTagShape: a record an HTTP redirect filled shows +// nothing it cannot vouch for. Once is one exclusive create per release +// (O_CREATE|O_EXCL), so of any number of sessions starting together exactly +// one takes the claim; a claim path that already exists, or a create that +// fails for any other reason, reports nothing, because a notice that cannot +// remember it was shown would repeat every session. Claims for earlier +// releases are left in place: removing one would be a second write, and each +// is an empty file. func TakeUnseenUpdate(pluginRoot, cwd string) (from, to string, ok bool) { - data := pluginDataDir(pluginRoot).dir - if data == "" || dataDirHazard(data, cwd) != "" { + dir, meta, claim := "", "", "" + if data := pluginDataDir(pluginRoot).dir; data != "" { + dir = data + meta = filepath.Join(data, "cache", "binary-meta") + claim = filepath.Join(data, "cache", updateShownPrefix) + } else if pluginRoot != "" { + dir = pluginRoot + meta = filepath.Join(pluginRoot, ".binary-meta") + claim = filepath.Join(pluginRoot, "."+updateShownPrefix) + } + if dir == "" || dataDirHazard(dir, cwd) != "" { return "", "", false } - cache := filepath.Join(data, "cache") - meta := filepath.Join(cache, "binary-meta") if metaField(meta, "transition_unseen") != "yes" { return "", "", false } @@ -50,11 +63,11 @@ func TakeUnseenUpdate(pluginRoot, cwd string) (from, to string, ok bool) { if !ReleaseTagShape.MatchString(from) || !ReleaseTagShape.MatchString(to) || from == to { return "", "", false } - marker := filepath.Join(cache, updateShownFile) - if metaField(marker, "release_tag") == to { + f, err := os.OpenFile(claim+to, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { return "", "", false } - if err := fsutil.WriteFileAtomic(marker, []byte("release_tag="+to+"\n"), 0o600); err != nil { + if err := f.Close(); err != nil { return "", "", false } return from, to, true diff --git a/internal/core/ahoy/unseen_update_test.go b/internal/core/ahoy/unseen_update_test.go index 79cfca326..4690640b1 100644 --- a/internal/core/ahoy/unseen_update_test.go +++ b/internal/core/ahoy/unseen_update_test.go @@ -3,6 +3,8 @@ package ahoy import ( "os" "path/filepath" + "sync" + "sync/atomic" "testing" ) @@ -36,8 +38,8 @@ func TestTakeUnseenUpdateShowsOnce(t *testing.T) { if !ok || from != "v0.11.1" || to != "v0.12.0" { t.Fatalf("the first session after an unseen swap must show it; got %q -> %q (%v)", from, to, ok) } - if got := metaField(filepath.Join(data, "cache", updateShownFile), "release_tag"); got != "v0.12.0" { - t.Errorf("the shown marker must record the release shown; got %q", got) + if _, err := os.Stat(filepath.Join(data, "cache", "update-shown-v0.12.0")); err != nil { + t.Errorf("the claim for the release shown must be cache/update-shown-v0.12.0: %v", err) } if _, _, ok := TakeUnseenUpdate("", cwd); ok { t.Errorf("the second session must not show the same update again") @@ -52,7 +54,7 @@ func TestTakeUnseenUpdateLeavesASeenSwapAlone(t *testing.T) { if _, _, ok := TakeUnseenUpdate("", t.TempDir()); ok { t.Errorf("a seen swap must not be shown again at session start") } - if _, err := os.Stat(filepath.Join(data, "cache", updateShownFile)); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(data, "cache", "update-shown-v0.12.0")); !os.IsNotExist(err) { t.Errorf("the session check must write nothing for a seen swap: %v", err) } } @@ -68,7 +70,7 @@ func TestTakeUnseenUpdateRefusesAHazardousDataDir(t *testing.T) { if _, _, ok := TakeUnseenUpdate("", t.TempDir()); ok { t.Errorf("a world-writable data dir must not be believed") } - if _, err := os.Stat(filepath.Join(data, "cache", updateShownFile)); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(data, "cache", "update-shown-v0.12.0")); !os.IsNotExist(err) { t.Errorf("nothing may be written into a hazardous data dir: %v", err) } } @@ -86,8 +88,79 @@ func TestTakeUnseenUpdateRefusesAMisshapenTag(t *testing.T) { if from, to, ok := TakeUnseenUpdate("", t.TempDir()); ok { t.Errorf("a misshapen tag must not be shown; got %q -> %q", from, to) } - if _, err := os.Stat(filepath.Join(data, "cache", updateShownFile)); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(data, "cache", "update-shown-v0.12.0")); !os.IsNotExist(err) { t.Errorf("nothing may be written for a misshapen tag: %v", err) } } } + +// TestTakeUnseenUpdateShowsOnceUnderConcurrentSessions: an autonomous run +// starts several sessions in one plugin root within the same second, and each +// runs the session check. The claim is one exclusive create per release, so +// exactly one of them shows the line, however they interleave. +func TestTakeUnseenUpdateShowsOnceUnderConcurrentSessions(t *testing.T) { + seedUnseenCache(t, unseenMeta) + cwd := t.TempDir() + const sessions = 16 + var shown atomic.Int32 + var ready, done sync.WaitGroup + start := make(chan struct{}) + for range sessions { + ready.Add(1) + done.Add(1) + go func() { + defer done.Done() + ready.Done() + <-start + if _, _, ok := TakeUnseenUpdate("", cwd); ok { + shown.Add(1) + } + }() + } + ready.Wait() + close(start) + done.Wait() + if n := shown.Load(); n != 1 { + t.Errorf("%d concurrent session starts must show the unseen update exactly once; shown %d times", sessions, n) + } +} + +// TestTakeUnseenUpdateFailsClosedOnAnExistingClaim: whatever already sits at +// the claim path (a claim, or a link to someone else's file) means the release +// was claimed, so nothing is shown and nothing is written through it. +func TestTakeUnseenUpdateFailsClosedOnAnExistingClaim(t *testing.T) { + data := seedUnseenCache(t, unseenMeta) + victim := filepath.Join(t.TempDir(), "victim") + if err := os.Symlink(victim, filepath.Join(data, "cache", "update-shown-v0.12.0")); err != nil { + t.Fatal(err) + } + if _, _, ok := TakeUnseenUpdate("", t.TempDir()); ok { + t.Errorf("an existing claim path must show nothing") + } + if _, err := os.Lstat(victim); !os.IsNotExist(err) { + t.Errorf("the claim must never be created through a link: %v", err) + } +} + +// TestTakeUnseenUpdateShowsAPerRootSwapOnce: with no plugin data directory the +// bootstrap runs in its degraded per-root mode and records the swap in the +// root's own .binary-meta, so the session check reads that record and claims +// the release beside it, as .update-shown-<tag>, with the same single write. +func TestTakeUnseenUpdateShowsAPerRootSwapOnce(t *testing.T) { + t.Setenv("CLAUDE_PLUGIN_DATA", "") + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".binary-meta"), []byte(unseenMeta), 0o644); err != nil { + t.Fatal(err) + } + cwd := t.TempDir() + from, to, ok := TakeUnseenUpdate(root, cwd) + if !ok || from != "v0.11.1" || to != "v0.12.0" { + t.Fatalf("the first session after an unseen per-root swap must show it; got %q -> %q (%v)", from, to, ok) + } + if _, err := os.Stat(filepath.Join(root, ".update-shown-v0.12.0")); err != nil { + t.Errorf("the per-root claim must be .update-shown-v0.12.0 in the plugin root: %v", err) + } + if _, _, ok := TakeUnseenUpdate(root, cwd); ok { + t.Errorf("the second session must not show the same per-root update again") + } +} From a5e772b7d4f99d6b00d0edec6b853dc7ca702c05 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:40:18 +0100 Subject: [PATCH 61/69] =?UTF-8?q?chore:=20resolve=20iss-2609300939590291?= =?UTF-8?q?=20=E2=80=94=20an=20unseen=20update=20is=20claimed=20once=20per?= =?UTF-8?q?=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609300939590291 Assisted-by: Claude:claude-opus-5-5 --- ...ce-only-breaks-under-concurrent-session-starts-ahoy.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609300939590291-once-only-breaks-under-concurrent-session-starts-ahoy.md (62%) diff --git a/.abcd/work/issues/open/iss-2609300939590291-once-only-breaks-under-concurrent-session-starts-ahoy.md b/.abcd/work/issues/resolved/iss-2609300939590291-once-only-breaks-under-concurrent-session-starts-ahoy.md similarity index 62% rename from .abcd/work/issues/open/iss-2609300939590291-once-only-breaks-under-concurrent-session-starts-ahoy.md rename to .abcd/work/issues/resolved/iss-2609300939590291-once-only-breaks-under-concurrent-session-starts-ahoy.md index 2434bedaa..6f2d22813 100644 --- a/.abcd/work/issues/open/iss-2609300939590291-once-only-breaks-under-concurrent-session-starts-ahoy.md +++ b/.abcd/work/issues/resolved/iss-2609300939590291-once-only-breaks-under-concurrent-session-starts-ahoy.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/unseen_update.go" remedy: "Claim each release with one exclusive create, os.OpenFile(cache/update-shown-<to>, O_WRONLY|O_CREATE|O_EXCL, 0o600), after the dataDirHazard and ReleaseTagShape checks; an existing path or any create error shows nothing (fail closed). O_EXCL is atomic per POSIX open(2), so exactly one concurrent caller wins; a 16-goroutine test holds it." +resolution: "The session check claims each release's unseen update with one exclusive create (O_CREATE|O_EXCL) of cache/update-shown-<tag>, or .update-shown-<tag> in the plugin root in the per-root mode; an existing path or any create error shows nothing, so exactly one of any number of concurrent session starts shows the line." +impact: fix +resolved_by: + commit: "4fb3575b8" --- Once-only breaks under concurrent session starts: ahoy.TakeUnseenUpdate read the cache/update-shown marker, then wrote it with a create-temp-and-rename, a read-then-write with no claim, so N sessions starting together in one plugin root (an autonomous run does this within a second) each saw no marker and each showed 'abcd updated from X to Y'; a 16-goroutine probe on one seeded cache showed the line 16 times, breaking the ruling CJ1b's once-only. + +## Grounds + +- pursued: 16 goroutines calling TakeUnseenUpdate on one seeded cache show the line exactly once (TestTakeUnseenUpdateShowsOnceUnderConcurrentSessions, 16 before the fix); a second show of one release from any interleaving, or a claim created through a link, would show it wrong. From 1b4700116ef2ef73e1d481a4afca3456b9d71c15 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:43:22 +0100 Subject: [PATCH 62/69] docs(decisions): correct the unseen-update claim's name in the CJ1 entry The installerMeta2 entry names the session start's single write `cache/update-shown`; the file its fix round built is `cache/update-shown-<new>` (per-root `.update-shown-<new>`). A dated correction line is appended at the end; the entry itself is untouched (DA002). Refs: iss-2609291942520919 Assisted-by: Claude:claude-opus-5-5 --- .abcd/work/DECISIONS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index c1da37a59..a0c345074 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2609,3 +2609,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-30 — Rulings CJ1 and CJ1b of the product thinker (2026-09-29), applied (lane installerMeta2, autonomous run A; Refs: iss-2609291942520919): an update of the abcd binary is announced once, by whatever swapped it, when the swap completes. CJ1, verbatim: "the INSTALLER (bootstrap.sh) writes the previous release tag into binary-meta when it swaps a binary; the session hook stays read-only. Follow-up: it REPLACES the setup_version comparison." CJ1b, verbatim: "ONCE ONLY. Preferred mechanism (the person's note): couple the notice to the install/update process itself — the installer shows 'updated from X to Y' when it concludes, so it is naturally linked to the installation and the session check need neither show it nor write anything. Only IF that is not possible (e.g. the swap happens where no one sees its output), the session check may write one small 'shown' marker as a single exception to its read-only rule." As built: `hooks/bootstrap.sh` records `previous_tag` in the `binary-meta` it writes at a cache or per-root swap and opens its success notice with `abcd updated from X to Y` (`update.UpdatedFormat`, the one wording, which `abcd update` also opens its receipt with); the session-start setup_version comparison (`ahoy.VersionTransition`) is removed, and ahoy's own `version.upgrade` gap stays. The exception applies: the bootstrap salvage in the UserPromptSubmit, PreToolUse and PreCompact hooks discards its output (`hooks/hooks.json`), so those runs pass `--unseen`, record `transition_unseen=yes`, and the next session start shows the line once and writes `cache/update-shown` in the plugin data directory, its single write. itd-111 criterion 6 is amended to match. - 2026-09-30 — The drain reads the drained repository's own eligibility record, which may loosen abcd's floors loudly, and it hands back every record still waiting on a person (the product thinker's rulings BX2 and H11 of 2026-09-29, applied by lane drainOwnRule of autonomous run A; partial of itd-82, whose spec stays open for the host judgement, the lane, the hand-back writes and the pace). BX2, verbatim: "the PROJECT MUST HOLD the eligibility decision in its own record (e.g. added at setup); drain refuses there until it does." H11, verbatim: "MAY LOOSEN abcd's floors (a project may let drain take major/critical and security issues). NOTE for the lane: make a loosened floor loud (drain --dry-run and the drain start name every floor the project loosened), and keep abcd's own repository at the stricter default." As built: the record is the one accepted decision record in the repository's `.abcd/development/decisions/adrs/` whose frontmatter carries `drain_categories` (an inline list, a subset of the fixable set), `drain_severities` (an inline list of severities), `drain_security` (`handback` or `take`) and `drain_remedy` (`required`, its only value, since the remedy is the brief a lane works from); abcd's own adr-2609291342092738 carries the strict baseline, which the binary also bundles as the measure a loosening is named against, and a test fails if abcd's record loosens anything. A repository without such a record, with one that is only proposed or superseded, with two accepted, or with one that misses, misspells, repeats or mis-values a field, is refused by `drain --dry-run` and bare `drain` alike, exit 2 and nothing written, never falling back to the baseline or a looser rule; widening the categories past the fixable set is refused as a decision by kind, which H11 does not name. Every loosened floor (`severity major`, `severity critical`, `security`) is named in the dry run's text, on stderr in both output modes, in `--json` as `loosened`, and in the start's refusal. `ahoy install` offers the baseline as an accepted record, written through the decision store's mint only on an answered yes; `--yes` skips it and reports `drain_rule.offered` under `optional_skipped`, as the routing offers are. The gap the remedy lanes found (50 of 54 dry-run-eligible records waiting on a ruling) is closed by BOTH hand-backs, each its own rule: a remedy opening "Waits on" (compared case-folded) is handed back as `waits-on-ruling`, because taking it would make the ruling the remedy waits on; and a record whose `deferred_after` names the current anchor tag is handed back as `deferred`, because a person carried it past this release and the waiver is that person's decision for the cycle. Both hold whatever the repository's record says. `capture defer` writes a deferral only onto a `major` or `critical` record, which H11 now lets a record take, but this ledger also carries hand-written deferrals on minor records (62 of the 231 open records on this branch are handed back as `deferred`), and the rule holds them back the same way. They are asked after the category and severity hand-backs, whose fix a ruling or a lapse would not change, and the ruling before the deferral, because it names which decision is owed; a record carrying both waits on both. The release tags are read only when an open record carries a deferral, and a failure to read them refuses the plan rather than letting a live deferral through. The threat is stated in the drain brief chapter: the record is a repository-authored file deciding what an unattended agent may do, so a contributor's pull request can loosen it; what guards it is that the record is committed history reviewed like code, a loosening is loud on every run, abcd's own repository keeps the baseline under a test, and the store is read inside the checkout so a symlink leaving it is refused. - 2026-09-30 — Correcting three points of the entry above after its review (lane fix-drainOwnRule of autonomous run A). The drain-rule offer of `ahoy install` is asked only of a person at a terminal, the itd-131 precedent the git identity question set, rather than behind a named opt-in flag: off a terminal neither its category question nor the offer is asked, so a piped answer stream keeps the order it had before the offer existed and a scripted yes never writes the record, and the run reports `drain_rule.offered` under `optional_skipped` naming the terminal as the way to be asked. The terminal gate was chosen over a `--drain-rule` flag because the record decides what an unattended agent may do, which a scripted answer is not a person's yes to, and a flag would hide the offer from the person at a terminal it is for. A checkout holding no release tag (a shallow clone fetches none) marks the anchor unknown rather than reading every deferral as lapsed: every record carrying a deferral is handed back as `deferred`, naming the missing tags and `git fetch --tags`, which keeps the rest of the dry run readable where refusing the whole plan would not. The rule's reader refuses, as malformed, a record that states any frontmatter key twice (not only a `drain_` key) and one whose frontmatter `id` disagrees with its file name, and reads each record through the capped trust-boundary reader, so a record that is a symlink or past the size cap refuses; every refusal of the rule exits 2 on the dry run as on the bare verb. +- 2026-09-30 — Correcting one name in the entry above that applies rulings CJ1 and CJ1b (lane installerMeta2, autonomous run A; Refs: iss-2609291942520919): the session start's single write is not `cache/update-shown` but `cache/update-shown-<new>`, one claim per release, named for the new tag and written beside the cache's `binary-meta`; in the degraded per-root mode it is `.update-shown-<new>` beside the plugin root's own `.binary-meta` (`updateShownPrefix` in `internal/core/ahoy/unseen_update.go`, as the fix round of that lane built it). The ruling and the rest of the entry stand. From 20218e01199dbdc403860910fd3f8a69c0df7a97 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:45:11 +0100 Subject: [PATCH 63/69] docs(intent): amend itd-2609211116005482 decision 9 for rulings CF1 and CF2 Decision 9 still read that a supersession chain ending at a decision (adr-N) blocks. The product thinker's rulings CF1 and CF2 of 2026-09-30 settle a chain ending at an accepted ADR, and a blocker sitting in disciplines/, as lane cfSettled built them in followBlocker. A dated amendment says so in the item; the ruled text before it stands. Refs: iss-2609300751191426 Assisted-by: Claude:claude-opus-5-5 --- ...d-build-next-picks-the-readiest-planned-intent-itself-wri.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.abcd/development/intents/planned/itd-2609211116005482-abcd-build-next-picks-the-readiest-planned-intent-itself-wri.md b/.abcd/development/intents/planned/itd-2609211116005482-abcd-build-next-picks-the-readiest-planned-intent-itself-wri.md index 61da4b25f..90f73668b 100644 --- a/.abcd/development/intents/planned/itd-2609211116005482-abcd-build-next-picks-the-readiest-planned-intent-itself-wri.md +++ b/.abcd/development/intents/planned/itd-2609211116005482-abcd-build-next-picks-the-readiest-planned-intent-itself-wri.md @@ -70,7 +70,7 @@ Ruled by the product thinker on 2026-09-21, in the interview that filed this dra 6. **The reason is the lane's first commit** on the lane branch (ruled 2026-09-21 on the design review's finding): a record-only commit the pick step makes at the branch base before the implementer starts, so it reaches `main` with the work and stays on the branch if the lane is discarded; the receipt verifier counts the implementer's commits from after it. 8. **The ordering is declared a heuristic** with the evidence in Why This Matters; it is revised on the record, never silently. 7. **Equal weights at first**, declared as the bundled default and revisable after ten picks; age breaks ties and is not scored. -9. **A superseded blocker waits on its replacement** (amended 2026-09-29 by ruling BZ2 of 2026-09-29, DECISIONS.md entry landing with lane recRulings; the ruling supersedes the reading this record carried until then, that every blocker outside `shipped/`, a superseded one included, blocks). The blocked check follows a blocker's `superseded_by` to the intent that replaced it, transitively, and the intent is a candidate only when the last intent of that chain has shipped. A chain that loops, ends at a record this checkout does not hold or at a decision (`adr-N`), or stops at a superseded record naming no successor blocks, and the refusal names the chain. +9. **A superseded blocker waits on its replacement** (amended 2026-09-29 by ruling BZ2 of 2026-09-29, DECISIONS.md entry landing with lane recRulings; the ruling supersedes the reading this record carried until then, that every blocker outside `shipped/`, a superseded one included, blocks). The blocked check follows a blocker's `superseded_by` to the intent that replaced it, transitively, and the intent is a candidate only when the last intent of that chain has shipped. A chain that loops, ends at a record this checkout does not hold or at a decision (`adr-N`), or stops at a superseded record naming no successor blocks, and the refusal names the chain. **Amendment, 2026-09-30:** the product thinker's rulings CF1 and CF2 of 2026-09-30 (DECISIONS.md, the entry recording the eighteen rulings of that day) settle two of those ends, as lane cfSettled built them in `followBlocker` (`internal/core/intent/startcheck.go`): a chain ending at a decision (`adr-N`) is settled when that ADR's status is `accepted` (CF1), and refuses naming the decision when it carries another status or no status, or when this checkout's decision store does not hold it; and a blocker, or the last intent of its chain, that sits in `disciplines/` is settled as a standing rule (CF2), as a shipped one is. A chain that loops, ends at an intent this checkout does not hold, or stops at a superseded record naming no successor still blocks, and a settled chain names the record that settled it. ## Open Questions From 4f6c76d91aadec9c25f1a9987bb3a90a973cc530 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:46:00 +0100 Subject: [PATCH 64/69] test(intent): pin a decision id two files claim, skipped until adrIdUnique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A supersession chain ending at an ADR whose id two files in the store claim (one accepted, one proposed) should refuse, not settle on the first file the scan reads. At this head it settles first-wins: recordid.LookupOne keeps the first file in scan order, so the accepted copy settles the edge. Watched without the skip: the test fails with OK=true and "itd-27 → adr-37 (accepted)". It is skipped with a comment naming lane adrIdUnique (fix/lint-adr-id-unique f6cd7b2d7), which makes the duplicate a refusal and lands later. Assisted-by: Claude:claude-opus-5-5 --- internal/core/intent/startcheck_test.go | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal/core/intent/startcheck_test.go b/internal/core/intent/startcheck_test.go index a52a7b659..e67d0d983 100644 --- a/internal/core/intent/startcheck_test.go +++ b/internal/core/intent/startcheck_test.go @@ -137,6 +137,44 @@ func TestStartBlockedRowFollowsASupersededBlockerToItsReplacement(t *testing.T) } } +// TestStartBlockedRowRefusesADecisionIdTwoFilesClaim: a supersession chain +// ending at a decision whose id two files in the decision store claim is not +// settled by whichever file the scan reads first. One file says accepted and +// the other proposed, so the standing of the decision is ambiguous, and the +// blocked check refuses naming the decision rather than settling the edge on +// the accepted copy. +func TestStartBlockedRowRefusesADecisionIdTwoFilesClaim(t *testing.T) { + // At this head the store's lookup (recordid.LookupOne) keeps the first file + // in scan order, so the accepted copy, which sorts first, settles the edge: + // the check settles first-wins rather than refusing. Watched: without this + // skip the test fails with OK=true and "itd-27 → adr-37 (accepted)". The + // uniqueness of an ADR id is made a refusal by lane adrIdUnique + // (fix/lint-adr-id-unique f6cd7b2d7), which lands later; it lifts this skip. + t.Skip("first-wins at this head: an ADR id two files claim is refused once lane adrIdUnique (fix/lint-adr-id-unique f6cd7b2d7) lands") + root := t.TempDir() + writeFile(t, root, filepath.Join(IntentsRelDir, BucketSuperseded, "itd-27-rec-27.md"), blockerRecord("itd-27", "adr-37")) + for name, status := range map[string]string{ + "0037-a-first-copy.md": "accepted", + "0037-b-second-copy.md": "proposed", + } { + writeFile(t, root, filepath.Join(filepath.FromSlash(decide.ADRsRelDir), name), adrRecord("adr-37", status)) + } + corpus, err := Load(root) + if err != nil { + t.Fatal(err) + } + row, err := startBlockedRow(root, corpus, "itd-10", "---\nid: itd-10\nblocked_by: [itd-27]\n---\n") + if err != nil { + t.Fatal(err) + } + if row.OK { + t.Fatalf("a decision id two files claim must refuse, not settle on the first file read: %+v", row) + } + if !strings.Contains(row.Detail, "adr-37") { + t.Errorf("the refusal must name the decision: %q", row.Detail) + } +} + // TestStartBlockedRowKeepsAPlainUnshippedBlocker holds the unchanged half: a // blocker that was not superseded blocks until it ships, and one this checkout // does not hold blocks too. From 718fd8243fbb60a818e02519aa64c43f323b0655 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:47:10 +0100 Subject: [PATCH 65/69] fix(guard): name no real home directory in the IFS-split test's comment The comment on TestDefaultWordsSplitOnANamedIFSTheWrittenCompareReads gave a developer account's absolute home path as its example HOME, and abcd lint's privacy-hygiene rule refuses the tree over it. The comment now says what the example needs, a macOS home whose account name holds a v, without the path. Captured in the same commit. Refs: iss-2609301046113575 Assisted-by: Claude:claude-opus-5-5 --- ...575-a-comment-in-the-guard-s-ifs-split-test.md | 15 +++++++++++++++ internal/core/guard/guardset_test.go | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .abcd/work/issues/open/iss-2609301046113575-a-comment-in-the-guard-s-ifs-split-test.md diff --git a/.abcd/work/issues/open/iss-2609301046113575-a-comment-in-the-guard-s-ifs-split-test.md b/.abcd/work/issues/open/iss-2609301046113575-a-comment-in-the-guard-s-ifs-split-test.md new file mode 100644 index 000000000..8339a1c26 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609301046113575-a-comment-in-the-guard-s-ifs-split-test.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2609301046113575" +slug: "a-comment-in-the-guard-s-ifs-split-test" +severity: "minor" +category: "documentation" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/guardset_test.go" +remedy: "Say what the example needs without the path: a macOS home under the Users directory whose account name holds a v, so the U and the v of an IFS of Uv split the home down to a bare root; abcd lint's privacy-hygiene rule (repolint rule_privacy.go, genericHomeRe) is the check that shows it gone." +--- + +A comment in the guard's IFS-split test (TestDefaultWordsSplitOnANamedIFSTheWrittenCompareReads, internal/core/guard/guardset_test.go) names a real macOS home directory, the developer account's own absolute path, as its example HOME, so abcd lint's privacy-hygiene rule fails the tree it lands in; it arrived with lane drainTrim and was found by integration 24b-2 before it reached main. diff --git a/internal/core/guard/guardset_test.go b/internal/core/guard/guardset_test.go index 3405d2232..a796e765f 100644 --- a/internal/core/guard/guardset_test.go +++ b/internal/core/guard/guardset_test.go @@ -43,7 +43,8 @@ func TestQuotedDefaultWordsTheWrittenCompareReads(t *testing.T) { // on the IFS the shell holds when it expands it, and an assignment in a // command of its own changes that IFS: `IFS=x; rm -rf ${U:-x/x}` hands rm // `""` and `/` on bash 3.2, /bin/sh, dash and bash 5.3. So is an unquoted -// `$HOME` (`IFS=Uv; rm -rf $HOME/x` hands rm `/` with HOME=/Users/dev). On a +// `$HOME` (`IFS=Uv; rm -rf $HOME/x` hands rm `/` when HOME is a macOS home +// whose account name holds a v, the U of Users and that v both splitting). On a // line that names IFS such a word refuses: the guard reads the split on the // default IFS only. A quoted word is not split, and a variable of unknown // value splits into text no more known than its value, so `while IFS= read From a586d62ff35df482f45a93989466d3a4d1b230e4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:47:15 +0100 Subject: [PATCH 66/69] =?UTF-8?q?chore:=20resolve=20iss-2609301046113575?= =?UTF-8?q?=20=E2=80=94=20the=20guard=20test's=20comment=20names=20no=20ho?= =?UTF-8?q?me=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609301046113575 Assisted-by: Claude:claude-opus-5-5 --- ...01046113575-a-comment-in-the-guard-s-ifs-split-test.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609301046113575-a-comment-in-the-guard-s-ifs-split-test.md (75%) diff --git a/.abcd/work/issues/open/iss-2609301046113575-a-comment-in-the-guard-s-ifs-split-test.md b/.abcd/work/issues/resolved/iss-2609301046113575-a-comment-in-the-guard-s-ifs-split-test.md similarity index 75% rename from .abcd/work/issues/open/iss-2609301046113575-a-comment-in-the-guard-s-ifs-split-test.md rename to .abcd/work/issues/resolved/iss-2609301046113575-a-comment-in-the-guard-s-ifs-split-test.md index 8339a1c26..dd55791c4 100644 --- a/.abcd/work/issues/open/iss-2609301046113575-a-comment-in-the-guard-s-ifs-split-test.md +++ b/.abcd/work/issues/resolved/iss-2609301046113575-a-comment-in-the-guard-s-ifs-split-test.md @@ -10,6 +10,14 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/guardset_test.go" remedy: "Say what the example needs without the path: a macOS home under the Users directory whose account name holds a v, so the U and the v of an IFS of Uv split the home down to a bare root; abcd lint's privacy-hygiene rule (repolint rule_privacy.go, genericHomeRe) is the check that shows it gone." +resolution: "The comment names no absolute home path; abcd lint reports 0 errors at this commit." +impact: internal +resolved_by: + commit: "718fd8243fbb60a818e02519aa64c43f323b0655" --- A comment in the guard's IFS-split test (TestDefaultWordsSplitOnANamedIFSTheWrittenCompareReads, internal/core/guard/guardset_test.go) names a real macOS home directory, the developer account's own absolute path, as its example HOME, so abcd lint's privacy-hygiene rule fails the tree it lands in; it arrived with lane drainTrim and was found by integration 24b-2 before it reached main. + +## Grounds + +- pursued: abcd lint's privacy-hygiene rule passes on the tree; a committed file naming a /Users/<name> path again would show it wrong. From 03c2b99f54609cdac45237b40611b76a4224df21 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:48:41 +0100 Subject: [PATCH 67/69] chore: recalibrate the reading windows at the integration tip Measured on a clean clone of a586d62ff (the 24b-2 merges and the follow-ups): widening 1,441,769 estimated tokens, window 1,440,000 -> 1,460,000; detection 1,450,805, 1,450,000 -> 1,470,000; entailment 411,876, window 420,000 kept (1.97% headroom, over the 1% rule), its measurement updated. 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 0f30bd998..82ec8d02e 100644 --- a/.abcd/config/reading-presets.json +++ b/.abcd/config/reading-presets.json @@ -60,10 +60,10 @@ "test" ], "window": { - "tokens_est": 1440000, - "measured_tokens_est": 1421126, - "measured_bytes": 5471337, - "measured_at": "d37b3f04964399db5a214460357b7bdad23f74c0" + "tokens_est": 1460000, + "measured_tokens_est": 1441769, + "measured_bytes": 5550812, + "measured_at": "a586d62ff35df482f45a93989466d3a4d1b230e4" } }, "entailment": { @@ -133,9 +133,9 @@ ], "window": { "tokens_est": 420000, - "measured_tokens_est": 406587, - "measured_bytes": 1565362, - "measured_at": "d37b3f04964399db5a214460357b7bdad23f74c0" + "measured_tokens_est": 411876, + "measured_bytes": 1585724, + "measured_at": "a586d62ff35df482f45a93989466d3a4d1b230e4" } }, "comparative": { @@ -216,10 +216,10 @@ "test" ], "window": { - "tokens_est": 1450000, - "measured_tokens_est": 1430162, - "measured_bytes": 5506125, - "measured_at": "d37b3f04964399db5a214460357b7bdad23f74c0" + "tokens_est": 1470000, + "measured_tokens_est": 1450805, + "measured_bytes": 5585600, + "measured_at": "a586d62ff35df482f45a93989466d3a4d1b230e4" } } } From 105617e2ab379f931bc495963e8e6a3e35a19ec6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:49:00 +0100 Subject: [PATCH 68/69] test(reachaudit): ratchet the core baseline down by one internal/core/release/ingest.go (targetNext) now calls intent.WithMintLock from outside its package, so its line leaves the unreached baseline: 192 unreached, 192 baselined. Assisted-by: Claude:claude-opus-5-5 --- internal/reachaudit/testdata/core-unreached.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/reachaudit/testdata/core-unreached.txt b/internal/reachaudit/testdata/core-unreached.txt index 4e91f67f7..a130eb853 100644 --- a/internal/reachaudit/testdata/core-unreached.txt +++ b/internal/reachaudit/testdata/core-unreached.txt @@ -57,7 +57,6 @@ internal/core/intent.ReEmitAudit internal/core/intent.ReEmitCommand internal/core/intent.UnmarkedConditionOrdinals internal/core/intent.Validate -internal/core/intent.WithMintLock internal/core/issuerecord.ParseBlock internal/core/issuerecord.ParseScalarOrList internal/core/lab.ValidID From 98045002848ce04609048f39cd81b7fcb79dbb6b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:12:27 +0100 Subject: [PATCH 69/69] fix(decide): build a stated ADR from the skeleton's pieces, not a delimiter search Semantic conflict between drainOwnRule and main: drainOwnRule's renderStated found the skeleton's closing delimiter with a private strings.Index on "---\n\n" and spelled a third delimiter literal, and main's TestNoPrivateDelimiterCompare (frontmatter) pins decide.go at two writer literals. The skeleton and the stated record are now built from the same two pieces, renderKeys (the opening delimiter and the nine keys at a given status) and renderCloseAndTitle (the closing delimiter and the H1), so nothing searches for a delimiter and the file spells two. Watched: TestNoPrivateDelimiterCompare failed in the integration preflight (4 literals, allowlist 2) and passes now; the decide, drainrule and ahoy drain-rule tests pass. Assisted-by: Claude:claude-opus-5-5 --- internal/core/decide/decide.go | 44 +++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/internal/core/decide/decide.go b/internal/core/decide/decide.go index 1710aa4b7..b540ef242 100644 --- a/internal/core/decide/decide.go +++ b/internal/core/decide/decide.go @@ -229,21 +229,11 @@ func adrPresent(repoRoot, id string) bool { // an answer nobody has given yet. func renderSkeleton(d Decision) string { var b strings.Builder - b.WriteString("---\n") - b.WriteString("id: " + d.ID + "\n") - b.WriteString("slug: " + d.Slug + "\n") // proposed, not accepted: the binary knows an id and a date, and cannot know // that a decision is in force. The author sets `accepted` in the change that // states the decision. - b.WriteString("status: proposed\n") - b.WriteString("date: " + d.Date + "\n") - b.WriteString("supersedes: null\n") - b.WriteString("superseded_by: null\n") - b.WriteString("related_intents: []\n") - b.WriteString("related_rfcs: []\n") - b.WriteString("related_adrs: []\n") - b.WriteString("---\n\n") - b.WriteString("# ADR-" + strings.TrimPrefix(d.ID, adrFamily+"-") + ": " + d.Title + "\n\n") + b.WriteString(renderKeys(d, "proposed")) + b.WriteString(renderCloseAndTitle(d)) b.WriteString("## Context\n\n") b.WriteString("_What forced the decision? What constraints were already locked?_\n\n") b.WriteString("## Decision\n\n") @@ -255,13 +245,35 @@ func renderSkeleton(d Decision) string { return b.String() } +// renderKeys writes the opening delimiter and the store's nine frontmatter +// keys with the status given, leaving the block open for a caller's extra keys. +func renderKeys(d Decision, status string) string { + var b strings.Builder + b.WriteString("---\n") + b.WriteString("id: " + d.ID + "\n") + b.WriteString("slug: " + d.Slug + "\n") + b.WriteString("status: " + status + "\n") + b.WriteString("date: " + d.Date + "\n") + b.WriteString("supersedes: null\n") + b.WriteString("superseded_by: null\n") + b.WriteString("related_intents: []\n") + b.WriteString("related_rfcs: []\n") + b.WriteString("related_adrs: []\n") + return b.String() +} + +// renderCloseAndTitle writes the closing delimiter and the `ADR-<id>: <Title>` +// H1 below it. +func renderCloseAndTitle(d Decision) string { + return "---\n\n# ADR-" + strings.TrimPrefix(d.ID, adrFamily+"-") + ": " + d.Title + "\n\n" +} + // renderStated lays out a stated record: the store's frontmatter keys with // `status: accepted`, the caller's extra keys, the H1, and the caller's body. +// It is built from the same two pieces as the skeleton, so it never searches +// the skeleton for a delimiter. func renderStated(d Decision, s Stated) string { - head := renderSkeleton(d) - head = head[:strings.Index(head, "---\n\n")] - head = strings.Replace(head, "status: proposed\n", "status: accepted\n", 1) - return head + s.Frontmatter + "---\n\n# ADR-" + strings.TrimPrefix(d.ID, adrFamily+"-") + ": " + d.Title + "\n\n" + s.Body + return renderKeys(d, "accepted") + s.Frontmatter + renderCloseAndTitle(d) + s.Body } // redactDecisionText sanitises the caller's title through the ONE canonical