From 61fb62c00156afbb803c2111182d7b2232752850 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:09:51 +0100 Subject: [PATCH 01/78] feat(recordid,readingitem): admission and surprise grammars and locator recordid gains ValidAdmissionID and ValidSurpriseID beside the four path-building grammars, so no admission or surprise path is built from an id nothing has matched. The readingitem leaf gains LocateAdmission and the adm family in ResolveOccasion, the one occasion resolver the reading chain shares, walking every run bucket under admissions/ with the same symlink refusal as the reading store. Part of spc-2609020626040342. Assisted-by: Claude:claude-opus-5-5 --- internal/core/readingitem/readingitem.go | 48 ++++++++++++++++++- internal/core/readingitem/readingitem_test.go | 35 ++++++++++++++ internal/core/recordid/valid.go | 15 ++++++ internal/core/recordid/valid_test.go | 32 +++++++++++++ 4 files changed, 128 insertions(+), 2 deletions(-) diff --git a/internal/core/readingitem/readingitem.go b/internal/core/readingitem/readingitem.go index cd0cb555e..a3c9157ad 100644 --- a/internal/core/readingitem/readingitem.go +++ b/internal/core/readingitem/readingitem.go @@ -42,6 +42,7 @@ type Family string const ( FamilyItem Family = issueschema.ReadingItemFamily // rdi-N, a reading item FamilyDisposition Family = issueschema.DispositionFamily // dsp-N, a disposition + FamilyAdmission Family = issueschema.AdmissionFamily // adm-N, an admission FamilyIntent Family = "itd" // itd-N, a shipped intent ) @@ -146,10 +147,50 @@ func LocateDisposition(issuesRoot, id string) (item, path string, err error) { } } +// LocateAdmission finds the one admission record carrying id across every run +// bucket under admissions/, and the run it is filed under. An admission is +// bucketed by RUN, so the walk is Paths' walk over the admission store: every +// run bucket is checked for a symlink, and the id is unique to the ledger. +func LocateAdmission(issuesRoot, id string) (run, path string, err error) { + if !recordid.ValidAdmissionID(id) { + return "", "", fmt.Errorf("invalid %s-N identifier: %q", issueschema.AdmissionFamily, id) + } + root := filepath.Join(issuesRoot, issueschema.AdmissionsDir) + if err := RefuseSymlinkedDir(root); err != nil { + return "", "", err + } + runs, err := os.ReadDir(root) + if err != nil && !os.IsNotExist(err) { + return "", "", err + } + var found []string + for _, e := range runs { + if !recordid.ValidReadingRunID(e.Name()) { + continue + } + dir := filepath.Join(root, e.Name()) + if err := RefuseSymlinkedDir(dir); err != nil { + return "", "", err + } + cand := filepath.Join(dir, id+".md") + if fi, err := os.Lstat(cand); err == nil && fi.Mode().IsRegular() { + found = append(found, cand) + } + } + switch len(found) { + case 0: + return "", "", fmt.Errorf("%w: %s is not an admission this ledger holds", ErrUnknown, id) + case 1: + return filepath.Base(filepath.Dir(found[0])), found[0], nil + default: + return "", "", fmt.Errorf("%w: %s is present under more than one run", ErrDuplicate, id) + } +} + // ResolveOccasion resolves id in one of the families the caller admits and // returns the path of the record it names. An id outside those families is -// refused by shape before any path is built. A reading item or a disposition -// resolves through the ledger walk above, under repoRoot's issue ledger; an +// refused by shape before any path is built. A reading item, a disposition or an +// admission resolves through the ledger walk above, under repoRoot's issue ledger; an // intent resolves only in repoRoot's intent store's shipped/ bucket, and a // record in any other bucket is refused naming the bucket. func ResolveOccasion(repoRoot, id string, families ...Family) (string, error) { @@ -173,6 +214,9 @@ func ResolveOccasion(repoRoot, id string, families ...Family) (string, error) { case FamilyDisposition: _, path, err := LocateDisposition(issuesRoot, id) return path, err + case FamilyAdmission: + _, path, err := LocateAdmission(issuesRoot, id) + return path, err case FamilyIntent: return resolveShippedIntent(repoRoot, id) } diff --git a/internal/core/readingitem/readingitem_test.go b/internal/core/readingitem/readingitem_test.go index cc1edaf8f..55b888bb5 100644 --- a/internal/core/readingitem/readingitem_test.go +++ b/internal/core/readingitem/readingitem_test.go @@ -147,3 +147,38 @@ func TestResolveOccasionReadsOnlyTheIntentStore(t *testing.T) { t.Errorf("a root holding no intent store: err = %v, want ErrUnknown", err) } } + +// TestLocateAdmissionFindsOneAcrossRuns is the admission half of the occasion +// resolver (spc-2609020626040342): a surprise may be occasioned by an admission, +// and an admission is bucketed by run, so the locator walks every run bucket +// exactly as Locate walks the reading store. +func TestLocateAdmissionFindsOneAcrossRuns(t *testing.T) { + root, ir := repo(t) + write(t, filepath.Join(ir, "admissions", "rdg-1", "adm-11.md"), "a") + write(t, filepath.Join(ir, "admissions", "rdg-2", "adm-22.md"), "b") + run, path, err := LocateAdmission(ir, "adm-22") + if err != nil || run != "rdg-2" || filepath.Base(path) != "adm-22.md" { + t.Fatalf("LocateAdmission = %q %q %v", run, path, err) + } + if _, _, err := LocateAdmission(ir, "adm-33"); !errors.Is(err, ErrUnknown) { + t.Errorf("an absent admission: err = %v, want ErrUnknown", err) + } + write(t, filepath.Join(ir, "admissions", "rdg-3", "adm-22.md"), "c") + if _, _, err := LocateAdmission(ir, "adm-22"); !errors.Is(err, ErrDuplicate) { + t.Errorf("an admission in two runs: err = %v, want ErrDuplicate", err) + } + if _, _, err := LocateAdmission(ir, "adm-../x"); err == nil || !strings.Contains(err.Error(), "invalid adm-N") { + t.Errorf("a malformed id: err = %v", err) + } + if got, err := ResolveOccasion(root, "adm-11", FamilyItem, FamilyAdmission, FamilyDisposition); err != nil || filepath.Base(got) != "adm-11.md" { + t.Errorf("ResolveOccasion(adm-11) = %q, %v", got, err) + } + outside := t.TempDir() + write(t, filepath.Join(outside, "adm-44.md"), "d") + if err := os.Symlink(outside, filepath.Join(ir, "admissions", "rdg-4")); err != nil { + t.Fatal(err) + } + if _, _, err := LocateAdmission(ir, "adm-44"); !errors.Is(err, ErrPathUnsafe) { + t.Errorf("a symlinked run bucket: err = %v, want ErrPathUnsafe", err) + } +} diff --git a/internal/core/recordid/valid.go b/internal/core/recordid/valid.go index 6a036e06a..46449d28b 100644 --- a/internal/core/recordid/valid.go +++ b/internal/core/recordid/valid.go @@ -17,6 +17,8 @@ var ( specIDRe = regexp.MustCompile(`^spc-[0-9]+$`) readingRunIDRe = regexp.MustCompile(`^rdg-[0-9]+$`) readingItemIDRe = regexp.MustCompile(`^rdi-[0-9]+$`) + admissionIDRe = regexp.MustCompile(`^adm-[0-9]+$`) + surpriseIDRe = regexp.MustCompile(`^srp-[0-9]+$`) ) // ValidIntentID reports whether id is a well-formed intent id (itd-N). @@ -44,6 +46,19 @@ func ValidReadingRunID(id string) bool { return readingRunIDRe.MatchString(id) } // about which files belong to a run. func ValidReadingItemID(id string) bool { return readingItemIDRe.MatchString(id) } +// ValidAdmissionID reports whether id is a well-formed admission id (adm-N). +// +// The admission verb builds `admissions//adm-N.md` out of it and the record +// dispatcher walks to that file by it, so it joins the grammars above for their +// reason: no path is built from an id nothing has matched +// (spc-2609020626040342). +func ValidAdmissionID(id string) bool { return admissionIDRe.MatchString(id) } + +// ValidSurpriseID reports whether id is a well-formed surprise id (srp-N), on +// the same terms as ValidAdmissionID: the surprise verb writes +// `surprises/srp-N.md` and the dispatcher reads it back. +func ValidSurpriseID(id string) bool { return surpriseIDRe.MatchString(id) } + // recordFilenameRe splits a record filename into its family prefix (group 1, // with its hyphen; empty for the ADR store's bare numeric form), its id number // (group 2), and its slug segment (group 3, empty when the name carries none). diff --git a/internal/core/recordid/valid_test.go b/internal/core/recordid/valid_test.go index 274ddb31c..5afbfafc9 100644 --- a/internal/core/recordid/valid_test.go +++ b/internal/core/recordid/valid_test.go @@ -33,3 +33,35 @@ func TestValidIDPredicates(t *testing.T) { } } } + +// TestAdmissionAndSurpriseIDGrammars pins the two families the admission and +// surprise verbs build paths from (spc-2609020626040342): an id nothing has +// matched never becomes a filename, so each family's grammar sits beside the +// four this package already holds. +func TestAdmissionAndSurpriseIDGrammars(t *testing.T) { + cases := []struct { + name string + valid func(string) bool + ok []string + bad []string + }{ + {"ValidAdmissionID", ValidAdmissionID, + []string{"adm-1", "adm-2609251200001234", "adm-0007"}, + []string{"", "null", "~", "adm-", "adm-1-slug", " adm-1", "adm-1\n", "ADM-1", "srp-1", "adm-../x", "adm-1/..", "rdi-1"}}, + {"ValidSurpriseID", ValidSurpriseID, + []string{"srp-1", "srp-2609251200001234", "srp-0007"}, + []string{"", "null", "~", "srp-", "srp-1-slug", " srp-1", "srp-1\n", "SRP-1", "adm-1", "srp-../x", "dsp-1"}}, + } + for _, c := range cases { + for _, id := range c.ok { + if !c.valid(id) { + t.Errorf("%s(%q) = false, want true", c.name, id) + } + } + for _, id := range c.bad { + if c.valid(id) { + t.Errorf("%s(%q) = true, want false", c.name, id) + } + } + } +} From 948934dd4fd68b488466bc11a439f10e7b218f3c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:09:53 +0100 Subject: [PATCH 02/78] feat(capture): admit and surprise over one gated disposition writer Every disposition now passes through writeDispositionLocked: one validator path and one ordering gate. At the widening position no disposition in any state is written until a committed comparative run names the item's run (ComparativeRunFor), refused with ErrNotCharacterised naming the run it is waiting on. The dsp-N mint moves inside the ledger lock, a behaviour change to `capture disposition` recorded by TestDispositionMintsUnderTheLock; redaction stays outside the lock. Admit writes the `accepted` disposition and adm-N as one act under the lock, both carrying the one folded ground held to grounds.ValidateText; over a standing acceptance it writes the admission alone, only on that acceptance's ground. It refuses a non-widening item, an admitted item, any other standing state, a contested or cyclic set, and a degenerate ground, and removes the disposition it wrote if the admission fails. Surprise writes srp-N as its own record, keyed by occasioned_by to an rdi-N, adm-N or dsp-N that resolves (issueschema.SurpriseOccasionFamilies), opening no disposition for writing. The occasioned_by reservation on the reading envelope is retired: the key on a reading record is now refused as any unknown key is. Part of spc-2609020626040342. Assisted-by: Claude:claude-opus-5-5 --- internal/core/capture/admit.go | 296 ++++++++++++++++++ internal/core/capture/admit_test.go | 355 ++++++++++++++++++++++ internal/core/capture/capture.go | 5 + internal/core/capture/itemfate.go | 7 +- internal/core/capture/promote_test.go | 3 + internal/core/capture/reading.go | 320 ++++++++++++------- internal/core/capture/reading_test.go | 198 +++++++++++- internal/core/capture/surprise.go | 171 +++++++++++ internal/core/capture/surprise_test.go | 159 ++++++++++ internal/core/issueschema/admission.go | 62 +++- internal/core/issueschema/reading.go | 11 - internal/core/issueschema/reading_test.go | 27 ++ 12 files changed, 1459 insertions(+), 155 deletions(-) create mode 100644 internal/core/capture/admit.go create mode 100644 internal/core/capture/admit_test.go create mode 100644 internal/core/capture/surprise.go create mode 100644 internal/core/capture/surprise_test.go create mode 100644 internal/core/issueschema/reading_test.go diff --git a/internal/core/capture/admit.go b/internal/core/capture/admit.go new file mode 100644 index 000000000..07b39aedb --- /dev/null +++ b/internal/core/capture/admit.go @@ -0,0 +1,296 @@ +package capture + +// The admission verb (itd-2609020625400194, spc-2609020626040342). +// +// At the widening position acceptance IS admission (itd-180's ruling), and an +// admission carries its grounds in a record of its own (itd-189's schema). So +// `capture admit` writes the two as ONE act under the ledger lock: the item's +// `accepted` disposition, carrying the grounds, and the admission record that +// joins the item to its run's candidate set, both carrying the same folded +// text. Where an `accepted` disposition already stands, the admission is written +// alone, and only on the ground that disposition states — so the two records +// can never give two reasons for one act by any path. +// +// The ruled order, characterise first and admit second, is the shared +// disposition writer's gate (requireCharacterised); the admission-alone branch +// meets the same gate, because it admits. + +import ( + "fmt" + "path/filepath" + + "github.com/intentdriven/abcd/internal/core/grounds" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/recordid" + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/termsafe" +) + +// AdmitRequest admits one widening item into its run's candidate set. +type AdmitRequest struct { + RepoRoot string + IssuesRoot string + // Item is the widening proposal admitted (rdi-N). + Item string + // Grounds is why it is admitted: free text with no `:` prefix, held to + // the substance floor every grounds primitive applies. + Grounds string +} + +// AdmitResult is the outcome of a successful Admit. +type AdmitResult struct { + // Admission is the admission record's id (adm-N) and Path its repo-relative + // path. + Admission string `json:"admission"` + Path string `json:"path"` + // Disposition is the item's `accepted` disposition — the one this act wrote, + // or the one already standing — and DispositionPath its repo-relative path. + Disposition string `json:"disposition"` + DispositionPath string `json:"disposition_path"` + // DispositionWritten reports whether this act wrote the disposition (true) + // or found an `accepted` one standing and wrote the admission alone (false). + DispositionWritten bool `json:"disposition_written"` + Item string `json:"item"` + Run string `json:"run"` + Grounds string `json:"grounds"` + Redacted int `json:"redacted,omitempty"` + Degraded string `json:"redaction_degraded,omitempty"` +} + +// Admit records one admission as one act. It refuses, writing nothing: an item +// that is not a widening item; an item already admitted; a standing disposition +// in any state but `accepted`, or an `accepted` one stating another ground; a +// contested or cyclic disposition set; a ground below the substance floor; and +// any admission before a committed comparative run names the item's run. +func Admit(req AdmitRequest) (AdmitResult, error) { + repoRoot, issuesRoot, err := resolveRoots(req.RepoRoot, req.IssuesRoot) + if err != nil { + return AdmitResult{}, err + } + if !recordid.ValidReadingItemID(req.Item) { + return AdmitResult{}, fmt.Errorf("%w: item %q does not match ^%s-[0-9]+$", + ErrMalformedFrontmatter, req.Item, issueschema.ReadingItemFamily) + } + // The ground is settled first, before the ledger is touched at all: a refusal + // here writes nothing, and says so. + ground, redacted, degraded, err := requireFreeGrounds(repoRoot, "admit", req.Grounds) + if err != nil { + return AdmitResult{}, err + } + if err := mutationPreamble(repoRoot, issuesRoot); err != nil { + return AdmitResult{}, err + } + // The pre-flight: a request that cannot be an admission refuses before the + // lock. Everything that decides the write is read again under it. + head, err := readItemHead(issuesRoot, req.Item) + if err != nil { + return AdmitResult{}, err + } + if err := requireWidening(head); err != nil { + return AdmitResult{}, err + } + + result := AdmitResult{Item: req.Item, Grounds: ground, Redacted: redacted, Degraded: degraded} + err = withLedgerLock(repoRoot, issuesRoot, func() error { + head, err := readItemHead(issuesRoot, req.Item) + if err != nil { + return err + } + if err := requireWidening(head); err != nil { + return err + } + result.Run = head.run + + // The one admitted-proposal probe, asked once: the admissions keyed on + // the (run, proposal) pair, and the dispositions standing over the item. + fate, err := itemFateIn(issuesRoot, head.run, head.item) + if err != nil { + return err + } + if fate.Cyclic { + return fmt.Errorf("%w: every disposition of %s is superseded by another, so none stands — a supersession cycle only a hand edit can repair; nothing written", + ErrInvariantViolation, head.item) + } + if len(fate.Admissions) > 0 { + return fmt.Errorf("%w: %s is already admitted into %s's candidate set (%s); an admission is written once, and nothing is written", + ErrInvariantViolation, head.item, head.run, renderList(fate.Admissions)) + } + + var dispPath string + switch len(fate.Dispositions) { + case 0: + // Both records: the disposition through the shared writer, which + // holds the ordering gate. + written, err := writeDispositionLocked(repoRoot, issuesRoot, head, DispositionRequest{ + Item: head.item, State: issueschema.DispositionAccepted, Grounds: ground, + }) + if err != nil { + return err + } + result.Disposition, result.DispositionWritten, dispPath = written.id, true, written.path + case 1: + standingID := fate.Dispositions[0] + path, err := requireStandingAcceptance(issuesRoot, head.item, standingID, ground) + if err != nil { + return err + } + // The admission-alone branch admits, so it meets the same gate. + if err := requireCharacterised(repoRoot, head); err != nil { + return err + } + result.Disposition, dispPath = standingID, path + default: + return fmt.Errorf("%w: %s has %d standing answers (%s), so which one is in force is a judgement the ledger does not contain, and an admission cannot stand on it; write `supersedes_disposition` into the records that are no longer meant to stand, by hand, until exactly one does (nothing written)", + ErrInvariantViolation, head.item, len(fate.Dispositions), renderList(fate.Dispositions)) + } + result.DispositionPath = fsutil.RepoRel(repoRoot, dispPath) + + admID, admPath, err := writeAdmissionLocked(repoRoot, issuesRoot, head, ground) + if err != nil { + if !result.DispositionWritten { + return err + } + // Both or neither: the disposition this act wrote is removed, on the + // shape writePair in core/reading carries. If the removal fails too, + // both failures are named, because the ledger then holds an + // acceptance whose admission never landed and the caller has to know. + if rmErr := removeContained(ledgerBase(repoRoot, issuesRoot), dispPath); rmErr != nil { + return fmt.Errorf("%w; and removing the disposition %s this act wrote also failed (%v), so it stands without its admission — remove it by hand", + err, result.Disposition, rmErr) + } + return fmt.Errorf("%w; the disposition %s this act wrote was removed, so nothing is written", err, result.Disposition) + } + result.Admission, result.Path = admID, fsutil.RepoRel(repoRoot, admPath) + return nil + }) + if err != nil { + return AdmitResult{}, err + } + return result, nil +} + +// requireWidening refuses an item at any position but widening, by name: +// admission is that position's warm act alone. +func requireWidening(head itemHead) error { + if head.position == issueschema.PositionWidening { + return nil + } + return fmt.Errorf("%w: %s is a %s item, and admission is the %s position's act alone — answer it with `abcd capture disposition` instead (nothing written)", + ErrInvariantViolation, head.item, head.position, issueschema.PositionWidening) +} + +// requireStandingAcceptance reads the one standing disposition of item and +// returns its path when it is an `accepted` disposition stating ground. Any +// other state refuses naming the disposition and its state; another ground +// refuses naming both texts, so the disposition and the admission cannot state +// two reasons for one act. +func requireStandingAcceptance(issuesRoot, item, id, ground string) (string, error) { + path := filepath.Join(issuesRoot, issueschema.DispositionsDir, item, id+".md") + content, err := readRecordGuarded(path) + if err != nil { + return "", err + } + fm, _, err := parseFrontmatterAndBody(content) + if err != nil { + return "", fmt.Errorf("%w: the standing disposition %s of %s does not parse: %v (nothing written)", + ErrMalformedFrontmatter, id, item, err) + } + if state := asString(fm["state"]); state != issueschema.DispositionAccepted { + return "", fmt.Errorf("%w: %s carries the standing disposition %s in the %q state; an admission stands only on `%s`, so supersede %s first if the answer has changed (nothing written)", + ErrInvariantViolation, item, id, state, issueschema.DispositionAccepted, id) + } + if standing := grounds.Fold(asString(fm["disposition_grounds"])); standing != ground { + return "", fmt.Errorf("%w: %s's standing acceptance %s states the ground %q, and this admission states %q; one act carries one ground, so admit on the standing ground or supersede %s (nothing written)", + ErrInvariantViolation, item, id, standing, ground, id) + } + return path, nil +} + +// writeAdmissionLocked mints and writes one admission record under +// admissions//. It must be called under the ledger lock, with ground +// already redacted, folded and held to the floor. +func writeAdmissionLocked(repoRoot, issuesRoot string, head itemHead, ground string) (string, string, error) { + id, err := minter.Mint(issueschema.AdmissionFamily) + if err != nil { + return "", "", err + } + fields, fm := admissionFields(id, head.run, head.item, ground) + if err := validateAdmissionStrict(fm); err != nil { + return "", "", err + } + content, err := buildIssueText(fields, "") + if err != nil { + return "", "", err + } + if err := ensureFamilyDir(issuesRoot, issueschema.AdmissionsDir, head.run); err != nil { + return "", "", err + } + path := filepath.Join(issuesRoot, issueschema.AdmissionsDir, head.run, id+".md") + if err := refuseExistingRecord(path, id); err != nil { + return "", "", err + } + if err := writeReadingRecord(ledgerBase(repoRoot, issuesRoot), path, []byte(content)); err != nil { + return "", "", err + } + return id, path, nil +} + +// admissionFields assembles one admission's frontmatter in the schema's order. +func admissionFields(id, run, proposal, ground string) ([]kv, map[string]any) { + fields := []kv{ + {"schema_version", 1}, + {"id", id}, + {"run", run}, + {"proposal", proposal}, + {"grounds", ground}, + } + fm := map[string]any{} + for _, f := range fields { + fm[f.key] = f.val + } + return fields, fm +} + +// validateAdmissionStrict holds an admission to issueschema's one declaration of +// the family: its closed key set, every required key present and non-blank, and +// each handle well-formed. +func validateAdmissionStrict(fm map[string]any) error { + if err := requireSchemaVersion(fm); err != nil { + return err + } + for k := range fm { + if !issueschema.AdmissionKnown[k] { + return fmt.Errorf("%w: unknown property %q on an admission", ErrMalformedFrontmatter, k) + } + } + for _, key := range issueschema.AdmissionRequired[1:] { + if err := requireNonBlankString(fm, key); err != nil { + return err + } + } + if id := asString(fm["id"]); !recordid.ValidAdmissionID(id) { + return fmt.Errorf("%w: id %q does not match ^%s-[0-9]+$", ErrMalformedFrontmatter, id, issueschema.AdmissionFamily) + } + if run := asString(fm["run"]); !recordid.ValidReadingRunID(run) { + return fmt.Errorf("%w: run %q does not match ^%s-[0-9]+$", ErrMalformedFrontmatter, run, issueschema.ReadingRunFamily) + } + if p := asString(fm["proposal"]); !recordid.ValidReadingItemID(p) { + return fmt.Errorf("%w: proposal %q does not match ^%s-[0-9]+$", ErrMalformedFrontmatter, p, issueschema.ReadingItemFamily) + } + return nil +} + +// requireFreeGrounds settles a free-text ground — one with no `:` prefix, +// the shape the admission's `grounds` and the disposition's +// `disposition_grounds` hold — against the floor every grounds primitive +// applies: redacted first, so no rewritten span reaches a value the floor has +// passed, then folded, then held to grounds.ValidateText, which refuses the +// empty, whitespace, control-character, too-short and vocabulary-only texts. +func requireFreeGrounds(repoRoot, verb, raw string) (string, int, string, error) { + red, n, degraded := redactLedgerText(repoRoot, raw) + folded := grounds.Fold(red) + if err := grounds.ValidateText(folded); err != nil { + return "", 0, "", fmt.Errorf("%s: %w: %v; nothing written", verb, ErrGroundsRefused, err) + } + return termsafe.EncodeHiddenRunes(folded), n, degraded, nil +} diff --git a/internal/core/capture/admit_test.go b/internal/core/capture/admit_test.go new file mode 100644 index 000000000..e1c52fa1d --- /dev/null +++ b/internal/core/capture/admit_test.go @@ -0,0 +1,355 @@ +package capture + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/issueschema" +) + +// A ground that clears the substance floor, used wherever the test is not about +// the ground. +const admitGround = "the configuration engages the frame the comparative reading characterised" + +// admitFixture ingests one widening item and commits the comparative run over +// its run, so the item is admissible. +func admitFixture(t *testing.T) (repo, ir, item string) { + t.Helper() + repo, ir, item = readingFixture(t, issueschema.PositionWidening) + commitComparativeRun(t, repo, "rdg-2608300000000002", fixtureRun) + return repo, ir, item +} + +// ledgerDigest hashes every file under the issues root, keyed by relative path, +// so a refusal can be proved to have left the ledger byte-identical. +func ledgerDigest(t *testing.T, ir string) string { + t.Helper() + var lines []string + _ = filepath.Walk(ir, func(p string, fi os.FileInfo, err error) error { + if err != nil || fi.IsDir() { + return nil + } + raw, rerr := os.ReadFile(p) + if rerr != nil { + t.Fatalf("read %s: %v", p, rerr) + } + sum := sha256.Sum256(raw) + rel, _ := filepath.Rel(ir, p) + lines = append(lines, rel+" "+hex.EncodeToString(sum[:])) + return nil + }) + sort.Strings(lines) + return strings.Join(lines, "\n") +} + +// admissionFiles lists the admission records filed under run. +func admissionFiles(t *testing.T, ir, run string) []string { + t.Helper() + entries, err := os.ReadDir(filepath.Join(ir, issueschema.AdmissionsDir, run)) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + var out []string + for _, e := range entries { + out = append(out, e.Name()) + } + return out +} + +// readFM parses one record's frontmatter. +func readFM(t *testing.T, path string) map[string]any { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + fm, _, err := parseFrontmatterAndBody(string(raw)) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + return fm +} + +// ac-1: with no disposition and a comparative run committed over the item's +// run, one act writes the `accepted` disposition and the admission under one +// lock; a second admit finds the admission by (run, proposal) and refuses. +func TestAdmitWritesBothRecordsAndRefusesTwice(t *testing.T) { + repo, ir, item := admitFixture(t) + res, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: admitGround}) + if err != nil { + t.Fatalf("Admit: %v", err) + } + if !res.DispositionWritten || res.Run != fixtureRun || res.Item != item { + t.Fatalf("result = %+v", res) + } + disp := readFM(t, filepath.Join(repo, filepath.FromSlash(res.DispositionPath))) + if disp["state"] != issueschema.DispositionAccepted || disp["item"] != item || disp["id"] != res.Disposition { + t.Fatalf("disposition = %v", disp) + } + adm := readFM(t, filepath.Join(repo, filepath.FromSlash(res.Path))) + if adm["proposal"] != item || adm["run"] != fixtureRun || adm["id"] != res.Admission || adm["grounds"] != admitGround { + t.Fatalf("admission = %v", adm) + } + if got := admissionFiles(t, ir, fixtureRun); len(got) != 1 || got[0] != res.Admission+".md" { + t.Fatalf("admissions = %v, want exactly %s.md", got, res.Admission) + } + + before := ledgerDigest(t, ir) + _, err = Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: admitGround}) + if !errors.Is(err, ErrInvariantViolation) || !strings.Contains(err.Error(), res.Admission) { + t.Fatalf("a second admit: err = %v, want ErrInvariantViolation naming %s", err, res.Admission) + } + if ledgerDigest(t, ir) != before { + t.Fatal("a refused second admit changed the ledger") + } +} + +// ac-2: where `accepted` already stands, the admission is written alone and the +// disposition's bytes are untouched. +func TestAdmitWritesTheAdmissionAloneOverAStandingAcceptance(t *testing.T) { + repo, ir, item := admitFixture(t) + d, err := Disposition(DispositionRequest{ + RepoRoot: repo, IssuesRoot: ir, Item: item, + State: issueschema.DispositionAccepted, Grounds: admitGround, + }) + if err != nil { + t.Fatalf("Disposition: %v", err) + } + dispPath := filepath.Join(repo, filepath.FromSlash(d.Path)) + dispBefore, _ := os.ReadFile(dispPath) + + res, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: admitGround}) + if err != nil { + t.Fatalf("Admit over a standing acceptance: %v", err) + } + if res.DispositionWritten || res.Disposition != d.ID { + t.Fatalf("result = %+v, want the standing %s and no new disposition", res, d.ID) + } + dispAfter, _ := os.ReadFile(dispPath) + if string(dispAfter) != string(dispBefore) { + t.Fatal("the standing disposition's bytes changed") + } + if got := dispositionFiles(t, ir); len(got) != 1 { + t.Fatalf("dispositions = %v, want the one standing record", got) + } + if got := admissionFiles(t, ir, fixtureRun); len(got) != 1 { + t.Fatalf("admissions = %v, want one", got) + } +} + +// The admission-alone branch requires the ground the standing acceptance states, +// so the disposition and the admission cannot give two reasons for one act by +// any path. The refusal names both texts. +func TestAdmissionAloneRequiresTheStandingGround(t *testing.T) { + repo, ir, item := admitFixture(t) + if _, err := Disposition(DispositionRequest{ + RepoRoot: repo, IssuesRoot: ir, Item: item, + State: issueschema.DispositionAccepted, Grounds: admitGround, + }); err != nil { + t.Fatalf("Disposition: %v", err) + } + before := ledgerDigest(t, ir) + other := "a different reason entirely for taking this configuration forward" + _, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: other}) + if !errors.Is(err, ErrInvariantViolation) { + t.Fatalf("a second ground: err = %v, want ErrInvariantViolation", err) + } + for _, want := range []string{admitGround, other} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal must name %q; got %v", want, err) + } + } + if ledgerDigest(t, ir) != before { + t.Fatal("a refused admit changed the ledger") + } + // The same ground, spaced differently, is the same ground once folded. + if _, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, + Grounds: " the configuration engages the frame\n the comparative reading characterised "}); err != nil { + t.Fatalf("the standing ground, refolded: %v", err) + } +} + +// ac-3's admission half: before any comparative run names the item's run, the +// admit refuses, names what it waits for, and writes nothing. +func TestAdmitRefusesBeforeTheComparativeRun(t *testing.T) { + repo, ir, item := readingFixture(t, issueschema.PositionWidening) + before := ledgerDigest(t, ir) + _, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: admitGround}) + if !errors.Is(err, ErrNotCharacterised) { + t.Fatalf("admit before the comparative run: err = %v, want ErrNotCharacterised", err) + } + if !strings.Contains(err.Error(), fixtureRun) || !strings.Contains(err.Error(), "comparative") { + t.Errorf("the refusal must name the run and the comparative run it waits for; got %v", err) + } + if ledgerDigest(t, ir) != before { + t.Fatal("a refused admit changed the ledger") + } + + // The admission-alone branch waits on the same gate: an acceptance written by + // hand before characterisation does not open it. + writeFile(t, filepath.Join(ir, issueschema.DispositionsDir, item, "dsp-2608300000000001.md"), + "---\nschema_version: 1\nid: \"dsp-2608300000000001\"\nitem: \""+item+"\"\nstate: \"accepted\"\n"+ + "disposition_grounds: \""+admitGround+"\"\n---\n\n") + _, err = Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: admitGround}) + if !errors.Is(err, ErrNotCharacterised) { + t.Fatalf("the admission-alone branch before the comparative run: err = %v, want ErrNotCharacterised", err) + } +} + +// A comparative run committed with an EMPTY item set is the not-exercised +// outcome (a widening run of fewer than two candidates), and it satisfies the +// gate exactly as a characterising run does. +func TestAdmitProceedsOnAnEmptyComparativeRun(t *testing.T) { + repo, ir, item := readingFixture(t, issueschema.PositionWidening) + writeFile(t, filepath.Join(repo, filepath.FromSlash(issueschema.ReadingsRecordDir), "rdg-2608300000000003", issueschema.RunRecordFileName), + `{"run_id":"rdg-2608300000000003","position":"comparative","candidate_run":"`+fixtureRun+`","candidates":1,"exercised":false,"records":[]}`) + if _, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: admitGround}); err != nil { + t.Fatalf("Admit after an empty comparative run: %v", err) + } +} + +// ac-4: a standing answer in any state but `accepted` refuses, naming its id and +// state. +func TestAdmitRefusesAStandingNonAcceptance(t *testing.T) { + for _, tc := range []DispositionRequest{ + {State: issueschema.DispositionDeclined, Grounds: "the proposal repeats a standing candidate"}, + {State: issueschema.DispositionHeld, ExitCondition: "the next widening run returns it again"}, + } { + t.Run(tc.State, func(t *testing.T) { + repo, ir, item := admitFixture(t) + tc.RepoRoot, tc.IssuesRoot, tc.Item = repo, ir, item + d, err := Disposition(tc) + if err != nil { + t.Fatalf("Disposition: %v", err) + } + before := ledgerDigest(t, ir) + _, err = Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: admitGround}) + if !errors.Is(err, ErrInvariantViolation) { + t.Fatalf("admit over %s: err = %v, want ErrInvariantViolation", tc.State, err) + } + if !strings.Contains(err.Error(), d.ID) || !strings.Contains(err.Error(), tc.State) { + t.Errorf("the refusal must name %s and %s; got %v", d.ID, tc.State, err) + } + if ledgerDigest(t, ir) != before { + t.Fatal("a refused admit changed the ledger") + } + }) + } +} + +// Admission is the widening position's warm act alone. +func TestAdmitRefusesANonWideningItem(t *testing.T) { + for _, position := range []string{"detection", "entailment", "comparative"} { + repo, ir, item := readingFixture(t, position) + before := ledgerDigest(t, ir) + _, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: admitGround}) + if !errors.Is(err, ErrInvariantViolation) || !strings.Contains(err.Error(), position) { + t.Fatalf("admit at %s: err = %v, want ErrInvariantViolation naming the position", position, err) + } + if ledgerDigest(t, ir) != before { + t.Fatalf("a refused admit at %s changed the ledger", position) + } + } +} + +// ac-5: a blank, whitespace or degenerate ground refuses before any mint, and +// the ledger is byte-identical afterwards. +func TestAdmitRefusesADegenerateGround(t *testing.T) { + repo, ir, item := admitFixture(t) + before := ledgerDigest(t, ir) + for _, g := range []string{"", " ", "\t\n", "ok", "admit admit admit", "no time now", "fine\vby me okay then"} { + _, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: g}) + if !errors.Is(err, ErrGroundsRefused) { + t.Errorf("ground %q: err = %v, want ErrGroundsRefused", g, err) + continue + } + if !strings.Contains(err.Error(), "nothing written") { + t.Errorf("ground %q: the refusal must say nothing was written; got %v", g, err) + } + } + if ledgerDigest(t, ir) != before { + t.Fatal("a refused ground changed the ledger") + } +} + +// A contested item — two standing answers — refuses as Disposition does. +func TestAdmitRefusesAContestedItem(t *testing.T) { + repo, ir, item := readingFixture(t, issueschema.PositionWidening) + commitComparativeRun(t, repo, "rdg-2608300000000002", fixtureRun) + writeRawDisposition(t, ir, item, "dsp-2608300000000001", issueschema.DispositionAccepted, "") + writeRawDisposition(t, ir, item, "dsp-2608300000000002", issueschema.DispositionDeclined, "") + before := ledgerDigest(t, ir) + _, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: admitGround}) + if !errors.Is(err, ErrInvariantViolation) || !strings.Contains(err.Error(), "dsp-2608300000000001") || + !strings.Contains(err.Error(), "dsp-2608300000000002") { + t.Fatalf("admit under contest: err = %v", err) + } + if ledgerDigest(t, ir) != before { + t.Fatal("a refused admit changed the ledger") + } +} + +// Both records or neither: a failure writing the admission removes the +// disposition this act wrote. +func TestAdmitRemovesTheDispositionWhenTheAdmissionWriteFails(t *testing.T) { + repo, ir, item := admitFixture(t) + before := dispositionFiles(t, ir) + orig := readingWriteHook + t.Cleanup(func() { readingWriteHook = orig }) + readingWriteHook = func(path string, data []byte) error { + if strings.Contains(filepath.ToSlash(path), "/"+issueschema.AdmissionsDir+"/") { + return errors.New("injected admission write failure") + } + return writeContained(ledgerBase(repo, ir), path, data) + } + _, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: admitGround}) + if err == nil || !strings.Contains(err.Error(), "injected admission write failure") { + t.Fatalf("err = %v, want the injected failure", err) + } + if got := dispositionFiles(t, ir); len(got) != len(before) { + t.Fatalf("the disposition survived a failed admission: %v", got) + } + if got := admissionFiles(t, ir, fixtureRun); len(got) != 0 { + t.Fatalf("admissions = %v, want none", got) + } +} + +// Both ids are minted under the ledger lock. +func TestAdmitMintsUnderTheLock(t *testing.T) { + repo, ir, item := admitFixture(t) + held := mintLockProbe(t, repo, ir) + if _, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: admitGround}); err != nil { + t.Fatalf("Admit: %v", err) + } + if len(*held) != 2 { + t.Fatalf("mints = %d, want 2 (the disposition and the admission)", len(*held)) + } + for i, h := range *held { + if !h { + t.Fatalf("mint %d ran outside the ledger lock", i+1) + } + } +} + +// The folded ground lands in both records: the disposition's +// disposition_grounds and the admission's grounds are one text. +func TestAdmissionAndDispositionCarryOneGround(t *testing.T) { + repo, ir, item := admitFixture(t) + res, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, + Grounds: " the configuration engages\n\tthe frame the comparative reading characterised\n"}) + if err != nil { + t.Fatalf("Admit: %v", err) + } + disp := readFM(t, filepath.Join(repo, filepath.FromSlash(res.DispositionPath))) + adm := readFM(t, filepath.Join(repo, filepath.FromSlash(res.Path))) + if disp["disposition_grounds"] != admitGround || adm["grounds"] != admitGround { + t.Fatalf("grounds: disposition %q, admission %q, want both %q", + disp["disposition_grounds"], adm["grounds"], admitGround) + } +} diff --git a/internal/core/capture/capture.go b/internal/core/capture/capture.go index 88ff739db..786495aa9 100644 --- a/internal/core/capture/capture.go +++ b/internal/core/capture/capture.go @@ -396,6 +396,11 @@ var ( ErrMissingRequiredField = errors.New("missing required field") // ErrPathUnsafe means the ledger root or a status dir is a symlink. ErrPathUnsafe = errors.New("path unsafe") + // ErrNotCharacterised means a disposition or an admission was asked for at + // the widening position before a committed comparative run named the item's + // run. The design characterises first and admits second, and the refusal + // names the run it is waiting on (spc-2609020626040342). + ErrNotCharacterised = errors.New("not yet characterised") ) // Field regexes mirroring issue.schema.json. diff --git a/internal/core/capture/itemfate.go b/internal/core/capture/itemfate.go index de2c4e18f..587b34748 100644 --- a/internal/core/capture/itemfate.go +++ b/internal/core/capture/itemfate.go @@ -62,8 +62,13 @@ func ItemFate(repoRoot, run, item string) (issueschema.ItemFate, error) { return issueschema.ItemFate{}, fmt.Errorf("%w: item %q does not match ^%s-[0-9]+$", ErrMalformedFrontmatter, item, issueschema.ReadingItemFamily) } - issuesRoot := filepath.Join(repoRoot, filepath.FromSlash(LedgerRelPath)) + return itemFateIn(filepath.Join(repoRoot, filepath.FromSlash(LedgerRelPath)), run, item) +} +// itemFateIn is ItemFate over an issues root the caller has already resolved — +// the form the admission verb asks under its lock, so the probe reads the same +// ledger the verb writes into. +func itemFateIn(issuesRoot, run, item string) (issueschema.ItemFate, error) { itemDir := filepath.Join(issuesRoot, issueschema.DispositionsDir, item) records, err := readDispositions(itemDir) if err != nil { diff --git a/internal/core/capture/promote_test.go b/internal/core/capture/promote_test.go index 7fe025771..5ce1168a7 100644 --- a/internal/core/capture/promote_test.go +++ b/internal/core/capture/promote_test.go @@ -389,6 +389,9 @@ func TestPromoteRefusesARefusedReadingItem(t *testing.T) { } { t.Run(tc.state, func(t *testing.T) { repo, ir, item := readingFixture(t, tc.position) + // A widening proposal is answered only after the comparative run over + // its run is committed (spc-2609020626040342's ordering gate). + commitComparativeRun(t, repo, "rdg-2608300000000002", fixtureRun) if _, err := Disposition(DispositionRequest{ RepoRoot: repo, IssuesRoot: ir, Item: item, State: tc.state, Grounds: "the constraint already covers it", diff --git a/internal/core/capture/reading.go b/internal/core/capture/reading.go index f48b5190a..e1132a9ba 100644 --- a/internal/core/capture/reading.go +++ b/internal/core/capture/reading.go @@ -56,10 +56,6 @@ type ReadingItem struct { // (issueschema.ReadingBodyFields). A field from another position's body is // refused: one record type, four bodies, and an item belongs to one of them. Body map[string]string - // OccasionedBy is RESERVED and dormant — the join key of the surprise entry, - // populated in Iteration 2. A populated value is refused until the shape is - // ruled, so the reservation is a behaviour rather than a comment. - OccasionedBy string } // IngestReadingRequest writes one run's items into the ledger. Position and @@ -305,74 +301,217 @@ func Disposition(req DispositionRequest) (DispositionResult, error) { // position would let a disposition assert the very rule it must satisfy. An // orphan disposition (no such item) is refused by this same path, because the // check has no position to reason with. - position, err := readingItemPosition(issuesRoot, req.Item) - if err != nil { - return DispositionResult{}, err - } - - id, err := minter.Mint(issueschema.DispositionFamily) - if err != nil { - return DispositionResult{}, err - } - fields, fm, redacted, degraded, err := dispositionFields(repoRoot, id, req) + // + // This read is the PRE-FLIGHT: it lets a malformed request refuse before the + // lock is taken. Everything that decides the write is read again under it. + head, err := readItemHead(issuesRoot, req.Item) if err != nil { return DispositionResult{}, err } - if err := validateDispositionStrict(fm, position); err != nil { - return DispositionResult{}, err - } - content, err := buildIssueText(fields, "") - if err != nil { + // Redaction happens outside the lock, as IngestReading's does: a scanner + // probes the machine identity and shells out to do it, and nothing under the + // lock needs one. + clean, redacted, degraded := redactDispositionRequest(repoRoot, req) + if err := prevalidateDisposition(clean, head.position); err != nil { return DispositionResult{}, err } - itemDir := filepath.Join(issuesRoot, issueschema.DispositionsDir, req.Item) - path := filepath.Join(itemDir, id+".md") + var written writtenDisposition err = withLedgerLock(repoRoot, issuesRoot, func() error { - if err := ensureFamilyDir(issuesRoot, issueschema.DispositionsDir, req.Item); err != nil { - return err - } - // A second answer to one item must say which one it replaces, and it must - // be checked HERE, under the lock: the standing disposition is a property of - // the directory as it is at the moment of the write. - standing, err := standingDispositions(itemDir) + locked, err := readItemHead(issuesRoot, req.Item) if err != nil { return err } - // More than one standing answer is refused outright, exactly as promote - // refuses it. A new disposition cannot untangle a contest: --supersedes - // retires one id and adds its own, so the set never shrinks and the caller - // is sent round a loop. Writing supersedes_disposition into the surplus - // records is the only thing that reduces it, and only a person can decide - // which of the standing answers is the surplus. - if len(standing) > 1 { - return fmt.Errorf("%w: %s has %d standing answers (%s), so a new disposition cannot say which is in force — "+ - "a fresh answer supersedes at most one of them and adds its own. Write `supersedes_disposition` into the "+ - "records that are no longer meant to stand, by hand, until exactly one does", - ErrInvariantViolation, req.Item, len(standing), renderList(standing)) - } - if req.Supersedes != "" && !containsString(standing, req.Supersedes) { - return fmt.Errorf("%w: supersedes_disposition names %q, which is not a standing disposition of %s (standing: %s)", - ErrInvariantViolation, req.Supersedes, req.Item, renderList(standing)) - } - if req.Supersedes == "" && len(standing) > 0 { - return fmt.Errorf("%w: %s already carries a standing disposition (%s); a second answer must cite the one it replaces (supersedes_disposition), so the record can say which is in force", - ErrInvariantViolation, req.Item, renderList(standing)) - } - if err := refuseExistingRecord(path, id); err != nil { - return err - } - return writeContained(ledgerBase(repoRoot, issuesRoot), path, []byte(content)) + written, err = writeDispositionLocked(repoRoot, issuesRoot, locked, clean) + return err }) if err != nil { return DispositionResult{}, err } return DispositionResult{ - ID: id, Item: req.Item, State: req.State, Position: position, - Path: fsutil.RepoRel(repoRoot, path), Redacted: redacted, Degraded: degraded, + ID: written.id, Item: req.Item, State: req.State, Position: written.position, + Path: fsutil.RepoRel(repoRoot, written.path), Redacted: redacted, Degraded: degraded, }, nil } +// itemHead is what the disposition and admission writers read off one reading +// record: its position, the run that returned it, and where it sits. +type itemHead struct { + item string + position string + run string + path string +} + +// readItemHead locates one reading item and validates it strictly, returning the +// facts every writer keyed to it decides on. The run is read off the record's +// own `run` and must agree with the directory it sits in: a record that says +// two things about which run returned it cannot say which candidate set it +// belongs to, and the ordering gate and the admission store are both keyed on +// that run. +func readItemHead(issuesRoot, item string) (itemHead, error) { + path, err := findReadingItem(issuesRoot, item) + if err != nil { + return itemHead{}, err + } + content, err := readRecordGuarded(path) + if err != nil { + return itemHead{}, err + } + fm, _, err := parseFrontmatterAndBody(content) + if err != nil { + return itemHead{}, err + } + if err := validateReadingStrict(fm); err != nil { + return itemHead{}, err + } + run := asString(fm["run"]) + if dir := filepath.Base(filepath.Dir(path)); dir != run { + return itemHead{}, fmt.Errorf("%w: %s declares run %s but is filed under %s; the record contradicts itself about which run returned it", + ErrInvariantViolation, item, run, dir) + } + return itemHead{item: item, position: asString(fm["position"]), run: run, path: path}, nil +} + +// redactDispositionRequest redacts every free-text value a disposition carries, +// with one scanner, before the lock is taken. +func redactDispositionRequest(repoRoot string, req DispositionRequest) (DispositionRequest, int, string) { + r := newLedgerRedactor(repoRoot) + total := 0 + scrub := func(s string) string { + if s == "" { + return s + } + out, n := r.redact(s) + total += n + return out + } + req.Grounds = scrub(req.Grounds) + req.ExitCondition = scrub(req.ExitCondition) + return req, total, r.Degraded() +} + +// dispositionPlaceholderID is a well-formed id the pre-flight validates with +// before the real one is minted under the lock. It is never written. +const dispositionPlaceholderID = issueschema.DispositionFamily + "-0" + +// prevalidateDisposition runs the schema check against the pre-flight position, +// so a request the writer would refuse refuses before the lock is taken and +// before anything is minted. The writer runs the same check again, under the +// lock, against the minted id and the position it re-read there. +func prevalidateDisposition(req DispositionRequest, position string) error { + _, fm := dispositionFields(dispositionPlaceholderID, req) + return validateDispositionStrict(fm, position) +} + +// writtenDisposition is what the shared writer landed. +type writtenDisposition struct { + id string + position string + path string +} + +// writeDispositionLocked is the ONE disposition write in this binary: every verb +// that answers a reading item — `capture disposition`, `capture admit`, and the +// scribe's ingest after it — routes through it, so every disposition passes one +// validator path and one ordering gate (spc-2609020626040342). It must be +// called under the ledger lock, with the item head read under that lock, and +// req already redacted. +// +// In order: the standing-set rules (a contest refuses; a second answer must cite +// the one it replaces), the ordering gate, the mint — under the lock, so the +// mint sees the tree it writes into, as mintUnusedItemID's does — the schema +// check against the minted id, and the write. +func writeDispositionLocked(repoRoot, issuesRoot string, head itemHead, req DispositionRequest) (writtenDisposition, error) { + itemDir := filepath.Join(issuesRoot, issueschema.DispositionsDir, head.item) + // A second answer to one item must say which one it replaces, and it must + // be checked HERE, under the lock: the standing disposition is a property of + // the directory as it is at the moment of the write. + standing, err := standingDispositions(itemDir) + if err != nil { + return writtenDisposition{}, err + } + // More than one standing answer is refused outright, exactly as promote + // refuses it. A new disposition cannot untangle a contest: --supersedes + // retires one id and adds its own, so the set never shrinks and the caller + // is sent round a loop. Writing supersedes_disposition into the surplus + // records is the only thing that reduces it, and only a person can decide + // which of the standing answers is the surplus. + if len(standing) > 1 { + return writtenDisposition{}, fmt.Errorf("%w: %s has %d standing answers (%s), so a new disposition cannot say which is in force — "+ + "a fresh answer supersedes at most one of them and adds its own. Write `supersedes_disposition` into the "+ + "records that are no longer meant to stand, by hand, until exactly one does", + ErrInvariantViolation, head.item, len(standing), renderList(standing)) + } + if req.Supersedes != "" && !containsString(standing, req.Supersedes) { + return writtenDisposition{}, fmt.Errorf("%w: supersedes_disposition names %q, which is not a standing disposition of %s (standing: %s)", + ErrInvariantViolation, req.Supersedes, head.item, renderList(standing)) + } + if req.Supersedes == "" && len(standing) > 0 { + return writtenDisposition{}, fmt.Errorf("%w: %s already carries a standing disposition (%s); a second answer must cite the one it replaces (supersedes_disposition), so the record can say which is in force", + ErrInvariantViolation, head.item, renderList(standing)) + } + if err := requireCharacterised(repoRoot, head); err != nil { + return writtenDisposition{}, err + } + + id, err := minter.Mint(issueschema.DispositionFamily) + if err != nil { + return writtenDisposition{}, err + } + fields, fm := dispositionFields(id, req) + if err := validateDispositionStrict(fm, head.position); err != nil { + return writtenDisposition{}, err + } + content, err := buildIssueText(fields, "") + if err != nil { + return writtenDisposition{}, err + } + if err := ensureFamilyDir(issuesRoot, issueschema.DispositionsDir, head.item); err != nil { + return writtenDisposition{}, err + } + path := filepath.Join(itemDir, id+".md") + if err := refuseExistingRecord(path, id); err != nil { + return writtenDisposition{}, err + } + if err := writeReadingRecord(ledgerBase(repoRoot, issuesRoot), path, []byte(content)); err != nil { + return writtenDisposition{}, err + } + return writtenDisposition{id: id, position: head.position, path: path}, nil +} + +// requireCharacterised is the ordering gate: at the widening position nothing is +// dispositioned and nothing is admitted until a committed comparative run names +// the item's run. The design characterises first and admits second (Step 2 +// precedes Step 4; companion section 8.3), and under the rule that commands are +// the write path a fixed order is a refusal in the writer, not a sentence in a +// protocol. +// +// It is keyed on the position alone. Every other position is answered with no +// comparative run anywhere, because the order is the widening reading's. +// +// The probe is ComparativeRunFor, which reads the committed run records the +// comparative channel writes. A comparative run committed with an empty item set +// — the position not exercised — satisfies it exactly as a characterising run +// does, and nothing else does: no mutable file anywhere records the outcome. +func requireCharacterised(repoRoot string, head itemHead) error { + if head.position != issueschema.PositionWidening { + return nil + } + comp, err := ComparativeRunFor(repoRoot, head.run) + if err != nil { + return err + } + if comp != "" { + return nil + } + return fmt.Errorf("%w: %s is a widening proposal of %s, and no committed comparative run names %s as its candidate_run yet; "+ + "the design characterises first and admits second, so no disposition (accepted, declined or held) and no admission is written "+ + "at the widening position until the comparative reading over %s is ingested — a comparative run committed with an empty item set, "+ + "the position not exercised, satisfies this too (nothing written)", + ErrNotCharacterised, head.item, head.run, head.run, head.run) +} + // readingFields assembles one reading record's ordered frontmatter and the map // its validator reads, redacting every free-text value on the way — a reading's // text lands in the committed ledger exactly as a capture's does. @@ -414,9 +553,6 @@ func readingFields(id, manifest string, req IngestReadingRequest, item ReadingIt } fm[f] = v } - if item.OccasionedBy != "" { - fm["occasioned_by"] = item.OccasionedBy - } return fields, fm } @@ -431,10 +567,7 @@ func redactReadingItem(r *ledgerRedactor, item ReadingItem) (ReadingItem, int) { total += n return out } - out := ReadingItem{ - Pattern: scrub(item.Pattern), - OccasionedBy: item.OccasionedBy, - } + out := ReadingItem{Pattern: scrub(item.Pattern)} if item.Body != nil { out.Body = make(map[string]string, len(item.Body)) for k, v := range item.Body { @@ -444,19 +577,10 @@ func redactReadingItem(r *ledgerRedactor, item ReadingItem) (ReadingItem, int) { return out, total } -// dispositionFields assembles one disposition's ordered frontmatter and map. -func dispositionFields(repoRoot, id string, req DispositionRequest) ([]kv, map[string]any, int, string, error) { - redacted := 0 - degraded := "" - scrub := func(s string) string { - out, n, d := redactLedgerText(repoRoot, s) - redacted += n - if d != "" { - degraded = d - } - return out - } - +// dispositionFields assembles one disposition's ordered frontmatter and map. It +// does no redaction: the request reaching it is already redacted +// (redactDispositionRequest), because it runs under the ledger lock. +func dispositionFields(id string, req DispositionRequest) ([]kv, map[string]any) { fields := []kv{ {"schema_version", 1}, {"id", id}, @@ -473,9 +597,8 @@ func dispositionFields(repoRoot, id string, req DispositionRequest) ([]kv, map[s if value == "" { return } - s := scrub(value) - fields = append(fields, kv{key, s}) - fm[key] = s + fields = append(fields, kv{key, value}) + fm[key] = value } add("disposition_grounds", req.Grounds) add("exit_condition", req.ExitCondition) @@ -495,7 +618,7 @@ func dispositionFields(repoRoot, id string, req DispositionRequest) ([]kv, map[s if req.HoldMoscow != "" { fm["hold_moscow"] = req.HoldMoscow } - return fields, fm, redacted, degraded, nil + return fields, fm } // ValidateReadingRecord parses one committed reading record and validates it @@ -584,12 +707,6 @@ func validateReadingStrict(fm map[string]any) error { } } - for _, f := range issueschema.ReservedSurpriseFields { - if v, present := fm[f]; present && strings.TrimSpace(asString(v)) != "" { - return fmt.Errorf("%w: %q is reserved and dormant — the surprise entry is a distinct record shape, populated once its shape is ruled; a populated value is refused rather than silently accepted", - ErrInvariantViolation, f) - } - } if v, present := fm["related_intents"]; present { items, isList := v.([]string) if !isList { @@ -687,28 +804,6 @@ func validateDispositionStrict(fm map[string]any, position string) error { return nil } -// readingItemPosition reads the position off a reading record, located by its id -// across every run directory. An id no run returned is an unknown item — the -// same sentinel an unknown issue id raises, because the fault is the same shape. -func readingItemPosition(issuesRoot, item string) (string, error) { - path, err := findReadingItem(issuesRoot, item) - if err != nil { - return "", err - } - content, err := readRecordGuarded(path) - if err != nil { - return "", err - } - fm, _, err := parseFrontmatterAndBody(content) - if err != nil { - return "", err - } - if err := validateReadingStrict(fm); err != nil { - return "", err - } - return asString(fm["position"]), nil -} - // findReadingItem locates a reading record by id across the run directories. // // It is a thin wrapper over readingitem.Locate, the one locator core/capture and @@ -896,9 +991,13 @@ func recordIDs(records []ReadingRecordRef) []string { return out } -// readingWriteHook, when non-nil, replaces the atomic write inside IngestReading. -// It is a test-only seam (nil in production, zero overhead) used to force a -// deterministic mid-batch write failure, mirroring stampWriteHook in promote.go. +// readingWriteHook, when non-nil, replaces the atomic write of every reading-ledger +// record this package writes: IngestReading's items, the shared disposition +// writer's record, and the admission and surprise records. It is a test-only +// seam (nil in production, zero overhead) used to force a deterministic write +// failure, mirroring stampWriteHook in promote.go — and, because every +// disposition passes through it, to prove that `capture disposition` and +// `capture admit` share one write path. var readingWriteHook func(path string, data []byte) error func writeReadingRecord(base, path string, data []byte) error { @@ -972,9 +1071,6 @@ func isReadingEnvelopeField(key string) bool { if containsString(issueschema.ReadingRequired, key) { return true } - if containsString(issueschema.ReservedSurpriseFields, key) { - return true - } return key == "related_intents" } diff --git a/internal/core/capture/reading_test.go b/internal/core/capture/reading_test.go index 6748411ad..43ae82720 100644 --- a/internal/core/capture/reading_test.go +++ b/internal/core/capture/reading_test.go @@ -6,8 +6,10 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/recordid" ) // readingFixture ingests one run of one detection item into a fresh ledger and @@ -192,24 +194,22 @@ func TestPopulatedHoldAxisRefused(t *testing.T) { } } -// The reserved surprise key gets the same posture as the hold axes: reserved in -// the family now, populated in Iteration 2, and refused until then. +// The surprise key is no longer reserved on the reading envelope: the join lives +// on the surprise record alone (spc-2609020626040342), so a reading record +// carrying it is refused as any unknown key is. func TestPopulatedSurpriseKeyRefused(t *testing.T) { - repo, ir := ledger(t) - _, err := IngestReading(IngestReadingRequest{ - RepoRoot: repo, IssuesRoot: ir, - Run: "rdg-2608300000000001", Manifest: "sha256:beef", - Position: "detection", Regime: "registrative", - Items: []ReadingItem{{ - Pattern: "a stated constraint", Body: bodyFor("detection"), - OccasionedBy: "iss-1", - }}, - }) - if !errors.Is(err, ErrInvariantViolation) { - t.Fatalf("populated occasioned_by: err = %v, want ErrInvariantViolation", err) + content := "---\nschema_version: 1\nid: \"rdi-1\"\nrun: \"rdg-2608300000000001\"\nmanifest: \"sha256:beef\"\n" + + "position: \"detection\"\nregime: \"registrative\"\npattern: \"a stated constraint\"\n" + for k, v := range bodyFor("detection") { + content += k + ": \"" + v + "\"\n" } - if !strings.Contains(err.Error(), "occasioned_by") { - t.Fatalf("the refusal must name the reserved field; got %v", err) + content += "occasioned_by: \"iss-1\"\n---\n" + _, err := ValidateReadingRecord(content) + if !errors.Is(err, ErrMalformedFrontmatter) { + t.Fatalf("occasioned_by on a reading record: err = %v, want ErrMalformedFrontmatter", err) + } + if !strings.Contains(err.Error(), "unknown property \"occasioned_by\"") { + t.Fatalf("the refusal must name the key as unknown; got %v", err) } } @@ -545,3 +545,169 @@ func TestDispositionRefusesUnderContest(t *testing.T) { }) } } + +// fixtureRun is the run readingFixture ingests into. +const fixtureRun = "rdg-2608300000000001" + +// commitComparativeRun writes the committed run record of a comparative run over +// candidateRun into the durable readings family — the marker +// ComparativeRunFor reads and the ordering gate waits for. It is written by hand +// because the gate only READS the channel's output (spc-2609020626040342, Out). +func commitComparativeRun(t *testing.T, repo, compRun, candidateRun string) { + t.Helper() + writeFile(t, filepath.Join(repo, filepath.FromSlash(issueschema.ReadingsRecordDir), compRun, issueschema.RunRecordFileName), + `{"run_id":"`+compRun+`","position":"comparative","candidate_run":"`+candidateRun+`"}`) +} + +// dispositionFiles lists every file under the dispositions tree, for a test that +// proves a refusal wrote nothing. +func dispositionFiles(t *testing.T, ir string) []string { + t.Helper() + var out []string + root := filepath.Join(ir, issueschema.DispositionsDir) + _ = filepath.Walk(root, func(p string, fi os.FileInfo, err error) error { + if err == nil && !fi.IsDir() { + out = append(out, p) + } + return nil + }) + return out +} + +// TestDispositionRefusesBeforeTheComparativeRun is ac-3's disposition half: at +// the widening position no disposition in any state is written until a +// committed comparative run names the item's run, and the refusal names the run +// and what it is waiting for. The ruled order is characterise first, admit +// second, and the gate lives in the writer every verb routes through. +func TestDispositionRefusesBeforeTheComparativeRun(t *testing.T) { + for _, tc := range []DispositionRequest{ + {State: issueschema.DispositionAccepted, Grounds: "the frame is engaged by this proposal"}, + {State: issueschema.DispositionDeclined, Grounds: "the proposal duplicates a standing candidate"}, + {State: issueschema.DispositionHeld, ExitCondition: "the comparative reading characterises it"}, + } { + t.Run(tc.State, func(t *testing.T) { + repo, ir, item := readingFixture(t, issueschema.PositionWidening) + tc.RepoRoot, tc.IssuesRoot, tc.Item = repo, ir, item + _, err := Disposition(tc) + if !errors.Is(err, ErrNotCharacterised) { + t.Fatalf("a %s disposition before the comparative run: err = %v, want ErrNotCharacterised", tc.State, err) + } + for _, want := range []string{fixtureRun, "comparative"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal must name %q; got %v", want, err) + } + } + if got := dispositionFiles(t, ir); len(got) != 0 { + t.Fatalf("a refused disposition wrote %v", got) + } + // A comparative run over ANOTHER widening run does not characterise + // this one. + commitComparativeRun(t, repo, "rdg-2608300000000009", "rdg-2608300000000008") + if _, err := Disposition(tc); !errors.Is(err, ErrNotCharacterised) { + t.Fatalf("a comparative run over another run satisfied the gate: err = %v", err) + } + commitComparativeRun(t, repo, "rdg-2608300000000002", fixtureRun) + if _, err := Disposition(tc); err != nil { + t.Fatalf("after the comparative run is committed: %v", err) + } + }) + } +} + +// TestTheGateIsKeyedOnTheWideningPositionAlone: every other position is +// dispositioned with no comparative run anywhere, because the ordering the +// design fixes is the widening reading's alone. +func TestTheGateIsKeyedOnTheWideningPositionAlone(t *testing.T) { + for _, position := range []string{"entailment", "detection"} { + repo, ir, item := readingFixture(t, position) + if _, err := Disposition(DispositionRequest{ + RepoRoot: repo, IssuesRoot: ir, Item: item, + State: issueschema.DispositionAccepted, Grounds: "the claim is one the record makes", + }); err != nil { + t.Fatalf("a %s item with no comparative run anywhere: %v", position, err) + } + } +} + +// TestDispositionMintsUnderTheLock records the behaviour change the shared +// writer makes to `capture disposition`: the dsp-N is minted INSIDE the ledger +// lock, as IngestReading's mint already is, so the mint sees the tree it writes +// into. The probe is a second acquisition from inside the mint's clock read: the +// flock is not reentrant, so a held lock reads as contention. +func TestDispositionMintsUnderTheLock(t *testing.T) { + repo, ir, item := readingFixture(t, "detection") + held := mintLockProbe(t, repo, ir) + if _, err := Disposition(DispositionRequest{ + RepoRoot: repo, IssuesRoot: ir, Item: item, + State: issueschema.DispositionAccepted, Grounds: "the claim is one the record makes", + }); err != nil { + t.Fatalf("Disposition: %v", err) + } + if len(*held) == 0 { + t.Fatal("the mint never ran") + } + for i, h := range *held { + if !h { + t.Fatalf("mint %d ran outside the ledger lock", i+1) + } + } +} + +// mintLockProbe installs a minter whose clock read asks whether the ledger lock +// is held, and returns the answers in mint order. +func mintLockProbe(t *testing.T, repo, ir string) *[]bool { + t.Helper() + var held []bool + origTimeout := lockTimeout + lockTimeout = 50 * time.Millisecond + t.Cleanup(func() { lockTimeout = origTimeout }) + setMinter(t, recordid.Minter{Now: func() time.Time { + err := withLedgerLock(repo, ir, func() error { return nil }) + held = append(held, errors.Is(err, ErrAllocatorContention)) + return time.Now().UTC() + }}) + return &held +} + +// TestDispositionAndAdmitShareOneWritePath proves the factored writer is the one +// both verbs call: the write seam every disposition passes through fires for a +// `capture disposition` and for a `capture admit`, on a path in the item-keyed +// dispositions tree, so neither verb can land a disposition by a route that +// skips the validator or the ordering gate. +func TestDispositionAndAdmitShareOneWritePath(t *testing.T) { + var seen []string + orig := readingWriteHook + t.Cleanup(func() { readingWriteHook = orig }) + install := func(repo, ir string) { + readingWriteHook = func(path string, data []byte) error { + if strings.Contains(filepath.ToSlash(path), "/"+issueschema.DispositionsDir+"/") { + seen = append(seen, path) + } + return writeContained(ledgerBase(repo, ir), path, data) + } + } + + repo, ir, item := readingFixture(t, "detection") + install(repo, ir) + if _, err := Disposition(DispositionRequest{ + RepoRoot: repo, IssuesRoot: ir, Item: item, + State: issueschema.DispositionAccepted, Grounds: "the claim is one the record makes", + }); err != nil { + t.Fatalf("Disposition: %v", err) + } + if len(seen) != 1 { + t.Fatalf("capture disposition: the shared write fired %d time(s), want 1", len(seen)) + } + + readingWriteHook = orig + repo, ir, item = readingFixture(t, issueschema.PositionWidening) + commitComparativeRun(t, repo, "rdg-2608300000000002", fixtureRun) + install(repo, ir) + if _, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, + Grounds: "the configuration engages the frame the comparative reading characterised"}); err != nil { + t.Fatalf("Admit: %v", err) + } + if len(seen) != 2 { + t.Fatalf("capture admit: the shared write fired %d time(s) in total, want 2", len(seen)) + } +} diff --git a/internal/core/capture/surprise.go b/internal/core/capture/surprise.go new file mode 100644 index 000000000..cc74e90cc --- /dev/null +++ b/internal/core/capture/surprise.go @@ -0,0 +1,171 @@ +package capture + +// The surprise verb (itd-2609020625400194, spc-2609020626040342). +// +// A surprise is something the researcher did not expect, recorded as its own +// act and its own record: surprises/srp-N.md, keyed by `occasioned_by` to the +// reading item, admission or disposition that occasioned it, with the surprise +// itself as the body (spc-67). No disposition file is opened for writing on this +// path, which is what "never a field on a disposition" means in code. + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/readingitem" + "github.com/intentdriven/abcd/internal/core/recordid" + "github.com/intentdriven/abcd/internal/fsutil" +) + +// SurpriseRequest records one surprise. +type SurpriseRequest struct { + RepoRoot string + IssuesRoot string + // OccasionedBy is the record that occasioned it: an rdi-N, adm-N or dsp-N + // this ledger holds, and nothing else. + OccasionedBy string + // Text is the surprise itself, held to the grounds floor. + Text string +} + +// SurpriseResult is the outcome of a successful Surprise. +type SurpriseResult struct { + ID string `json:"id"` + OccasionedBy string `json:"occasioned_by"` + Path string `json:"path"` + Redacted int `json:"redacted,omitempty"` + Degraded string `json:"redaction_degraded,omitempty"` +} + +// occasionFamilies maps issueschema's one declaration of the families a +// surprise may be occasioned by onto the leaf resolver's names. +func occasionFamilies() []readingitem.Family { + out := make([]readingitem.Family, 0, len(issueschema.SurpriseOccasionFamilies)) + for _, f := range issueschema.SurpriseOccasionFamilies { + out = append(out, readingitem.Family(f)) + } + return out +} + +// Surprise writes one surprise entry. It refuses, writing nothing, an occasion +// that is not a handle of the three families or that resolves to nothing, and a +// text below the substance floor. +func Surprise(req SurpriseRequest) (SurpriseResult, error) { + repoRoot, issuesRoot, err := resolveRoots(req.RepoRoot, req.IssuesRoot) + if err != nil { + return SurpriseResult{}, err + } + occasion := req.OccasionedBy + if !issueschema.ValidSurpriseOccasion(occasion) { + return SurpriseResult{}, fmt.Errorf("%w: --occasioned-by %q is not a handle of %s; a surprise is keyed to the record that occasioned it, never to prose (nothing written)", + ErrMalformedFrontmatter, occasion, occasionFamilyList()) + } + text, redacted, degraded, err := requireFreeGrounds(repoRoot, "surprise", req.Text) + if err != nil { + return SurpriseResult{}, err + } + // The occasion is resolved before anything is minted, and again under the + // lock, where the write is decided. + if err := resolveSurpriseOccasion(repoRoot, occasion); err != nil { + return SurpriseResult{}, err + } + if err := mutationPreamble(repoRoot, issuesRoot); err != nil { + return SurpriseResult{}, err + } + + result := SurpriseResult{OccasionedBy: occasion, Redacted: redacted, Degraded: degraded} + err = withLedgerLock(repoRoot, issuesRoot, func() error { + if err := resolveSurpriseOccasion(repoRoot, occasion); err != nil { + return err + } + id, err := minter.Mint(issueschema.SurpriseFamily) + if err != nil { + return err + } + fields, fm := surpriseFields(id, occasion) + if err := validateSurpriseStrict(fm); err != nil { + return err + } + content, err := buildIssueText(fields, text) + if err != nil { + return err + } + dir := filepath.Join(issuesRoot, issueschema.SurprisesDir) + if err := safeMkdirLeaf(dir); err != nil { + return err + } + path := filepath.Join(dir, id+".md") + if err := refuseExistingRecord(path, id); err != nil { + return err + } + if err := writeReadingRecord(ledgerBase(repoRoot, issuesRoot), path, []byte(content)); err != nil { + return err + } + result.ID, result.Path = id, fsutil.RepoRel(repoRoot, path) + return nil + }) + if err != nil { + return SurpriseResult{}, err + } + return result, nil +} + +// resolveSurpriseOccasion resolves the occasion through the one occasion +// resolver the reading chain shares, refusing one that names nothing. +func resolveSurpriseOccasion(repoRoot, occasion string) error { + if _, err := readingitem.ResolveOccasion(repoRoot, occasion, occasionFamilies()...); err != nil { + return fmt.Errorf("--occasioned-by %s does not resolve: %w (nothing written)", occasion, wrapLocatorErr(err)) + } + return nil +} + +// occasionFamilyList renders the admitted families for a refusal. +func occasionFamilyList() string { + names := make([]string, 0, len(issueschema.SurpriseOccasionFamilies)) + for _, f := range issueschema.SurpriseOccasionFamilies { + names = append(names, f+"-N") + } + return strings.Join(names, ", ") +} + +// surpriseFields assembles one surprise's frontmatter in the schema's order. +func surpriseFields(id, occasion string) ([]kv, map[string]any) { + fields := []kv{ + {"schema_version", 1}, + {"id", id}, + {"occasioned_by", occasion}, + } + fm := map[string]any{} + for _, f := range fields { + fm[f.key] = f.val + } + return fields, fm +} + +// validateSurpriseStrict holds a surprise to issueschema's declaration of the +// family: its closed key set, every required key present, and the occasion a +// handle of one of the three families. +func validateSurpriseStrict(fm map[string]any) error { + if err := requireSchemaVersion(fm); err != nil { + return err + } + for k := range fm { + if !issueschema.SurpriseKnown[k] { + return fmt.Errorf("%w: unknown property %q on a surprise", ErrMalformedFrontmatter, k) + } + } + for _, key := range issueschema.SurpriseRequired[1:] { + if err := requireNonBlankString(fm, key); err != nil { + return err + } + } + if id := asString(fm["id"]); !recordid.ValidSurpriseID(id) { + return fmt.Errorf("%w: id %q does not match ^%s-[0-9]+$", ErrMalformedFrontmatter, id, issueschema.SurpriseFamily) + } + if occ := asString(fm["occasioned_by"]); !issueschema.ValidSurpriseOccasion(occ) { + return fmt.Errorf("%w: occasioned_by %q is not a handle of %s", ErrMalformedFrontmatter, occ, occasionFamilyList()) + } + return nil +} diff --git a/internal/core/capture/surprise_test.go b/internal/core/capture/surprise_test.go new file mode 100644 index 000000000..20c47cf87 --- /dev/null +++ b/internal/core/capture/surprise_test.go @@ -0,0 +1,159 @@ +package capture + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/issueschema" +) + +const surpriseText = "the comparative reading ranked the proposal nobody expected to survive first" + +// surpriseFiles lists the surprise records. +func surpriseFiles(t *testing.T, ir string) []string { + t.Helper() + entries, err := os.ReadDir(filepath.Join(ir, issueschema.SurprisesDir)) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + var out []string + for _, e := range entries { + out = append(out, e.Name()) + } + return out +} + +// dispositionsDigest hashes the dispositions tree alone. +func dispositionsDigest(t *testing.T, ir string) string { + t.Helper() + return ledgerDigest(t, filepath.Join(ir, issueschema.DispositionsDir)) +} + +// ac-6: a surprise is its own record, written to surprises/srp-N.md, and no +// disposition file is touched on the way — "never a field on a disposition" +// in code. +func TestSurpriseIsItsOwnRecord(t *testing.T) { + repo, ir, item := admitFixture(t) + adm, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: admitGround}) + if err != nil { + t.Fatalf("Admit: %v", err) + } + before := dispositionsDigest(t, ir) + res, err := Surprise(SurpriseRequest{RepoRoot: repo, IssuesRoot: ir, OccasionedBy: adm.Disposition, Text: surpriseText}) + if err != nil { + t.Fatalf("Surprise: %v", err) + } + if res.OccasionedBy != adm.Disposition { + t.Fatalf("result = %+v", res) + } + if got := surpriseFiles(t, ir); len(got) != 1 || got[0] != res.ID+".md" { + t.Fatalf("surprises = %v, want exactly %s.md", got, res.ID) + } + raw, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(res.Path))) + if err != nil { + t.Fatal(err) + } + fm, body, err := parseFrontmatterAndBody(string(raw)) + if err != nil { + t.Fatal(err) + } + if len(fm) != len(issueschema.SurpriseRequired) || fm["id"] != res.ID || fm["occasioned_by"] != adm.Disposition { + t.Fatalf("frontmatter = %v", fm) + } + if strings.TrimSpace(body) != surpriseText { + t.Fatalf("body = %q, want the surprise itself", body) + } + if dispositionsDigest(t, ir) != before { + t.Fatal("writing a surprise touched the dispositions tree") + } +} + +// The occasion is a closed form: an rdi-N, adm-N or dsp-N that resolves in this +// ledger, and nothing else. Each family resolves; an unknown id, a malformed +// id, prose, and an id of a fourth family refuse, naming it, before anything is +// minted. +func TestSurpriseRefusesAnUnresolvedOccasion(t *testing.T) { + repo, ir, item := admitFixture(t) + adm, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: admitGround}) + if err != nil { + t.Fatalf("Admit: %v", err) + } + for _, occ := range []string{item, adm.Admission, adm.Disposition} { + if _, err := Surprise(SurpriseRequest{RepoRoot: repo, IssuesRoot: ir, OccasionedBy: occ, Text: surpriseText}); err != nil { + t.Errorf("occasion %s: %v", occ, err) + } + } + before := ledgerDigest(t, ir) + for _, occ := range []string{ + "rdi-9999", "adm-9999", "dsp-9999", // unknown + "rdi-", "rdi-x", "RDI-1", " " + item, "", // malformed + "a consequence nobody predicted", // prose + "itd-1", "iss-1", "srp-1", // a fourth family + } { + _, err := Surprise(SurpriseRequest{RepoRoot: repo, IssuesRoot: ir, OccasionedBy: occ, Text: surpriseText}) + if err == nil { + t.Errorf("occasion %q: accepted, want a refusal", occ) + continue + } + if occ != "" && !strings.Contains(err.Error(), strings.TrimSpace(occ)) { + t.Errorf("occasion %q: the refusal must name it; got %v", occ, err) + } + } + if ledgerDigest(t, ir) != before { + t.Fatal("a refused surprise changed the ledger") + } +} + +// The surprise's text is held to the same floor every grounds-shaped field in +// this workstream applies. +func TestSurpriseRefusesADegenerateText(t *testing.T) { + repo, ir, item := readingFixture(t, "detection") + before := ledgerDigest(t, ir) + for _, text := range []string{"", " ", "odd", "no time now"} { + _, err := Surprise(SurpriseRequest{RepoRoot: repo, IssuesRoot: ir, OccasionedBy: item, Text: text}) + if !errors.Is(err, ErrGroundsRefused) || !strings.Contains(err.Error(), "nothing written") { + t.Errorf("text %q: err = %v, want ErrGroundsRefused saying nothing was written", text, err) + } + } + if ledgerDigest(t, ir) != before { + t.Fatal("a refused surprise changed the ledger") + } +} + +// The body is redacted before it is written: a surprise lands in the committed +// ledger exactly as a capture does. +func TestSurpriseRedactsItsBody(t *testing.T) { + repo, ir, item := readingFixture(t, "detection") + home := t.TempDir() + t.Setenv("HOME", home) + res, err := Surprise(SurpriseRequest{RepoRoot: repo, IssuesRoot: ir, OccasionedBy: item, + Text: "the reading cited " + filepath.Join(home, "notes", "draft.md") + " which nobody had shown it"}) + if err != nil { + t.Fatalf("Surprise: %v", err) + } + if res.Redacted == 0 { + t.Fatal("Redacted = 0, want the home path counted") + } + raw, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(res.Path))) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), home) { + t.Fatalf("the committed surprise carries the caller's home root:\n%s", raw) + } +} + +// Its id is minted under the ledger lock. +func TestSurpriseMintsUnderTheLock(t *testing.T) { + repo, ir, item := readingFixture(t, "detection") + held := mintLockProbe(t, repo, ir) + if _, err := Surprise(SurpriseRequest{RepoRoot: repo, IssuesRoot: ir, OccasionedBy: item, Text: surpriseText}); err != nil { + t.Fatalf("Surprise: %v", err) + } + if len(*held) != 1 || !(*held)[0] { + t.Fatalf("mint lock probe = %v, want one mint under the lock", *held) + } +} diff --git a/internal/core/issueschema/admission.go b/internal/core/issueschema/admission.go index 44ae1bdf5..cab8acfdf 100644 --- a/internal/core/issueschema/admission.go +++ b/internal/core/issueschema/admission.go @@ -1,5 +1,7 @@ package issueschema +import "strings" + // The step-2 admission records (itd-189, spc-67). // // Declining a proposal costs nothing epistemically; ADMITTING one is where the @@ -9,24 +11,23 @@ package issueschema // with careful judgement and with abdication, and only a record that says which // happened can tell the two apart. // -// Three shapes are named by the intent and only ONE of them is new here: +// Three shapes are named by the intent, and two of them are new families: // // - The ADMISSION record (adm-N) is new, and this file declares it. // - The DECLINED proposal is not a new record type. It is the disposition // record of reading.go in its `declined` state, which the widening position // already reserves — a second store for a state the disposition vocabulary // holds would be a parallel answer to one question. -// - The SURPRISE entry's join key is reserved on the reading-record envelope -// (ReservedSurpriseFields, reading.go); what this file adds is its own -// family, its own store and its own required set, which is what makes it a -// record rather than a field on something else. +// - The SURPRISE entry is its own family, its own store and its own required +// set, which is what makes it a record rather than a field on something +// else. Its join key lives on the surprise record alone: the reservation +// spc-58 placed on the reading envelope is retired (spc-2609020626040342). // -// This cycle ships the SCHEMAS, not the commands that write them: no reading has -// run, so there is nothing to write yet. The shapes are wired to the gate that -// reads committed records (core/lint's record_schema) rather than to a verb, so -// a hand-written admission record with a blank `grounds` is a blocker finding -// from the day this lands. What is hand-run is WHO writes the file, never -// whether anything checks it. +// The schemas shipped first and were wired to the gate that reads committed +// records (core/lint's record_schema), so a hand-written admission with a blank +// `grounds` is a blocker finding. The verbs that write them are `abcd capture +// admit` and `abcd capture surprise` (spc-2609020626040342), which cannot write +// a blank ground; the gate stays for the record written by hand. // The two families this design adds. Both mint through recordid.Minter.Mint like // every other record family in this workstream (adr-45): a UTC stamp plus four @@ -75,12 +76,43 @@ var AdmissionRequired = []string{"schema_version", "id", "run", "proposal", "gro var AdmissionKnown = knownSet(AdmissionRequired) // SurpriseRequired is every property a surprise entry carries. `occasioned_by` -// names whatever occasioned it and is the record's whole join: an rdi-N -// detection, an adm-N admission, or a consequence named in prose. The surprise -// ITSELF is the record's body, where a reader can write more than a frontmatter -// value holds. +// names the record that occasioned it and is the record's whole join. The +// surprise ITSELF is the record's body, where a reader can write more than a +// frontmatter value holds. var SurpriseRequired = []string{"schema_version", "id", "occasioned_by"} +// SurpriseOccasionFamilies is the CLOSED set of families a surprise's +// `occasioned_by` may name: a reading item (rdi-N), an admission (adm-N) or a +// disposition (dsp-N) — and nothing else, prose included +// (spc-2609020626040342). It is the one list: the surprise verb resolves the +// occasion over it, and core/lint's srp join holds a committed record to it, so +// a hand-written prose occasion is a finding rather than a join that joins +// nothing. +var SurpriseOccasionFamilies = []string{ReadingItemFamily, AdmissionFamily, DispositionFamily} + +// ValidSurpriseOccasion reports whether v is VERBATIM a handle of one of +// SurpriseOccasionFamilies: the family's own prefix, one hyphen and digits, +// with nothing around it. +func ValidSurpriseOccasion(v string) bool { + for _, f := range SurpriseOccasionFamilies { + rest, ok := strings.CutPrefix(v, f+"-") + if !ok || rest == "" { + continue + } + digits := true + for i := 0; i < len(rest); i++ { + if rest[i] < '0' || rest[i] > '9' { + digits = false + break + } + } + if digits { + return true + } + } + return false +} + // SurpriseKnown is the surprise entry's allow-list. var SurpriseKnown = knownSet(SurpriseRequired) diff --git a/internal/core/issueschema/reading.go b/internal/core/issueschema/reading.go index 551503a29..3277703aa 100644 --- a/internal/core/issueschema/reading.go +++ b/internal/core/issueschema/reading.go @@ -132,14 +132,6 @@ var ReadingRequired = []string{ // properties below. A key outside it is refused, exactly as it is on an issue. var ReadingKnown = readingKnown() -// ReservedSurpriseFields are reserved and DORMANT (spc-58, out of scope: "the -// surprise entry, reserved here and populated in Iteration 2"). The reading's -// output, the researcher's disposition, and the surprise that occasions -// abduction are three acts and three records; this reserves the third's join -// key in the family now. A populated value is REFUSED until the shape is ruled, -// so the reservation is a behaviour rather than a comment. -var ReservedSurpriseFields = []string{"occasioned_by"} - // DispositionRequired is what every disposition carries whatever its state. // `disposition_grounds` is NOT here: it is required on every state except // `held`, which is a per-state rule rather than a schema-wide one. @@ -291,9 +283,6 @@ func readingKnown() map[string]bool { known[f] = true } } - for _, f := range ReservedSurpriseFields { - known[f] = true - } return known } diff --git a/internal/core/issueschema/reading_test.go b/internal/core/issueschema/reading_test.go new file mode 100644 index 000000000..2aaa7b11f --- /dev/null +++ b/internal/core/issueschema/reading_test.go @@ -0,0 +1,27 @@ +package issueschema + +import "testing" + +// TestOccasionedByIsNoLongerReservedOnTheEnvelope: the reservation spc-58 placed +// on the reading envelope is retired with the surprise verb +// (spc-2609020626040342). The join lives on the surprise record and nowhere +// else, so a reading record carrying the key is refused as any unknown key is, +// and the surprise's own required set is where the key is declared. +func TestOccasionedByIsNoLongerReservedOnTheEnvelope(t *testing.T) { + if ReadingKnown["occasioned_by"] { + t.Error("occasioned_by is still a known key on the reading record") + } + if !SurpriseKnown["occasioned_by"] { + t.Error("occasioned_by must be declared on the surprise record") + } + for _, f := range []string{ReadingItemFamily, AdmissionFamily, DispositionFamily} { + if !ValidSurpriseOccasion(f + "-12") { + t.Errorf("%s-12 must be a valid surprise occasion", f) + } + } + for _, v := range []string{"", "prose", "itd-1", "srp-1", "RDI-1", " rdi-1", "rdi-", "rdi-1x"} { + if ValidSurpriseOccasion(v) { + t.Errorf("ValidSurpriseOccasion(%q) = true, want false", v) + } + } +} From 48228c73da7e1c95270641b85a1e934d27e45d71 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:09:55 +0100 Subject: [PATCH 03/78] feat(record): abcd adm-N and srp-N describe the record and its joins IDRe admits adm, srp and rfm; Describe gains the admission and surprise cases (status `admitted` and `recorded`, links to the run, proposal and standing disposition, or to the occasion), with no next move. rfm is refused naming the reframe spec that owns its description. The reading families stay outside the dispatcher. Part of spc-2609020626040342. Assisted-by: Claude:claude-opus-5-5 --- internal/core/record/record.go | 165 +++++++++++++++++++++++++--- internal/core/record/record_test.go | 94 ++++++++++++++++ 2 files changed, 244 insertions(+), 15 deletions(-) diff --git a/internal/core/record/record.go b/internal/core/record/record.go index 95fd58663..50d0ac2da 100644 --- a/internal/core/record/record.go +++ b/internal/core/record/record.go @@ -1,5 +1,5 @@ // Package record is the read side of `abcd `: dispatch on a record id — -// iss-N, itd-N, spc-N, adr-N — and report what the record is, its links, and +// iss-N, itd-N, spc-N, adr-N, and the ledger's adm-N and srp-N — and report what the record is, its links, and // the concrete next move for its lifecycle state (spc-26). It is a leaf // package over the capture, intent, and spec read paths plus a thin adr // reader; nothing imports it back, and nothing here writes or knows a @@ -20,13 +20,20 @@ import ( "github.com/intentdriven/abcd/internal/core/capture" "github.com/intentdriven/abcd/internal/core/frontmatter" "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/recordid" "github.com/intentdriven/abcd/internal/core/spec" ) // IDRe is the family gate: the only positional shapes the root command routes // here. Anything else stays on the unknown-command path, byte-for-byte. -var IDRe = regexp.MustCompile(`^(iss|itd|spc|adr)-[0-9]+$`) +// +// adm, srp and rfm joined in one edit (spc-2609020626040342): rfm because the +// reframe spec (spc-2609020626048705) lands after it and inherits the gate +// rather than making a second edit — its Describe case is that spec's. The +// reading families rdi, dsp and rdg stay outside it, the residual spc-67 names. +var IDRe = regexp.MustCompile(`^(iss|itd|spc|adr|adm|srp|rfm)-[0-9]+$`) // adrsRelDir is where decisions live. A file is -.md carrying an // `id: adr-N` frontmatter line, in either of the store's two id vintages: the @@ -50,9 +57,9 @@ var ErrSkippedRecord = errors.New("skipped on read") // superseded_by) as present. type Description struct { ID string `json:"id"` - Family string `json:"family"` // issue | intent | spec | adr + Family string `json:"family"` // issue | intent | spec | adr | admission | surprise Title string `json:"title"` - Status string `json:"status"` // folder/bucket, directory-as-truth + Status string `json:"status"` // folder/bucket, directory-as-truth; admitted | recorded for the two folderless ledger families Path string `json:"path"` Links map[string]string `json:"links,omitempty"` NextMoves []string `json:"next_moves,omitempty"` @@ -93,7 +100,7 @@ func RecommendedVerbPaths() []string { func Describe(repoRoot, id string) (Description, error) { m := IDRe.FindStringSubmatch(id) if m == nil { - return Description{}, fmt.Errorf("record: id %q does not match ^(iss|itd|spc|adr)-[0-9]+$", id) + return Description{}, fmt.Errorf("record: id %q does not match %s", id, IDRe.String()) } switch m[1] { case "iss": @@ -102,6 +109,12 @@ func Describe(repoRoot, id string) (Description, error) { return describeIntent(repoRoot, id) case "spc": return describeSpec(repoRoot, id) + case "adm": + return describeAdmission(repoRoot, id) + case "srp": + return describeSurprise(repoRoot, id) + case "rfm": + return Description{}, fmt.Errorf("record: %s — the reframe family is dispatched by its own record's spec (spc-2609020626048705), which has not landed; no reframe store exists to read", id) default: return describeADR(repoRoot, id) } @@ -462,6 +475,102 @@ func describeADR(repoRoot, id string) (Description, error) { return Description{}, fmt.Errorf("record: %s not found in %s", canonical, adrsRelDir) } +// describeAdmission renders one admission read-only: the grounds as its title, +// and its joins — the run, the proposal and where it sits, and the standing +// disposition over the proposal. The family has no folder, so its status is +// `admitted`; it emits no next move, because an admission is an answer already +// given. +func describeAdmission(repoRoot, id string) (Description, error) { + issuesRoot := filepath.Join(repoRoot, filepath.FromSlash(capture.LedgerRelPath)) + _, path, err := readingitem.LocateAdmission(issuesRoot, id) + if err != nil { + return Description{}, fmt.Errorf("record: %s not found in %s/%s: %w", id, capture.LedgerRelPath, issueschema.AdmissionsDir, err) + } + fields, _ := readRecordHead(path, id) + d := Description{ + ID: id, + Family: "admission", + Title: headValue(fields, "grounds", id), + Status: "admitted", + Path: filepath.ToSlash(relTo(repoRoot, path)), + Links: map[string]string{}, + } + run, proposal := headValue(fields, "run", ""), headValue(fields, "proposal", "") + if run != "" { + d.Links["run"] = run + } + if proposal != "" { + d.Links["proposal"] = proposal + if _, ppath, err := readingitem.Locate(issuesRoot, proposal); err == nil { + d.Links["proposal_path"] = filepath.ToSlash(relTo(repoRoot, ppath)) + } + if recordid.ValidReadingRunID(run) { + if fate, err := capture.ItemFate(repoRoot, run, proposal); err == nil && len(fate.Dispositions) > 0 { + d.Links["disposition"] = strings.Join(fate.Dispositions, ", ") + } + } + } + return d, nil +} + +// describeSurprise renders one surprise read-only: its body — the surprise +// itself — as its title, and the occasion it is keyed to. The family has no +// folder, so its status is `recorded`; it emits no next move. +func describeSurprise(repoRoot, id string) (Description, error) { + if !recordid.ValidSurpriseID(id) { + return Description{}, fmt.Errorf("record: malformed srp id %q", id) + } + issuesRoot := filepath.Join(repoRoot, filepath.FromSlash(capture.LedgerRelPath)) + dir := filepath.Join(issuesRoot, issueschema.SurprisesDir) + if err := readingitem.RefuseSymlinkedDir(dir); err != nil { + return Description{}, fmt.Errorf("record: %s: %w", id, err) + } + path := filepath.Join(dir, id+".md") + if fi, err := os.Lstat(path); err != nil || !fi.Mode().IsRegular() { + return Description{}, fmt.Errorf("record: %s not found in %s/%s", id, capture.LedgerRelPath, issueschema.SurprisesDir) + } + fields, body := readRecordHeadAndBody(path) + d := Description{ + ID: id, + Family: "surprise", + Title: firstLine(body, id), + Status: "recorded", + Path: filepath.ToSlash(relTo(repoRoot, path)), + Links: map[string]string{}, + } + if occ := headValue(fields, "occasioned_by", ""); occ != "" { + d.Links["occasioned_by"] = occ + fams := make([]readingitem.Family, 0, len(issueschema.SurpriseOccasionFamilies)) + for _, f := range issueschema.SurpriseOccasionFamilies { + fams = append(fams, readingitem.Family(f)) + } + if issueschema.ValidSurpriseOccasion(occ) { + if opath, err := readingitem.ResolveOccasion(repoRoot, occ, fams...); err == nil { + d.Links["occasion_path"] = filepath.ToSlash(relTo(repoRoot, opath)) + } + } + } + return d, nil +} + +// headValue reads one frontmatter value, unquoted, or fallback when it is absent +// or null. +func headValue(fields map[string]frontmatter.Field, key, fallback string) string { + v := strings.Trim(strings.TrimSpace(fields[key].Value), `"'`) + if v == "" || frontmatter.IsNull(v) { + return fallback + } + return v +} + +// relTo renders abs relative to repoRoot, or abs itself when it is not under it. +func relTo(repoRoot, abs string) string { + if rel, err := filepath.Rel(repoRoot, abs); err == nil && !strings.HasPrefix(rel, "..") { + return rel + } + return abs +} + // maxRecordHeadBytes bounds the head read (trust boundary, mirroring the // stores' own caps). const maxRecordHeadBytes = 256 * 1024 @@ -476,28 +585,54 @@ const maxRecordHeadBytes = 256 * 1024 // vet the intent/spec paths before this function ever sees them; the adr path // has no store of its own, so this is its only guard. func readRecordHead(absPath, fallbackTitle string) (map[string]frontmatter.Field, string) { + fields, lines, ok := readGuardedLines(absPath) + if !ok { + return fields, fallbackTitle + } + for _, ln := range lines { + if strings.HasPrefix(ln, "# ") { + return fields, strings.TrimSpace(strings.TrimPrefix(ln, "# ")) + } + } + return fields, fallbackTitle +} + +// readRecordHeadAndBody reads a record file on readRecordHead's guarded terms +// and returns its frontmatter fields and the body after the closing delimiter. +// A refused file yields no fields and an empty body. +func readRecordHeadAndBody(absPath string) (map[string]frontmatter.Field, string) { + fields, lines, ok := readGuardedLines(absPath) + if !ok || len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { + return fields, "" + } + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "---" { + return fields, strings.Join(lines[i+1:], "\n") + } + } + return fields, "" +} + +// readGuardedLines is the guarded read both head readers share: O_NOFOLLOW and +// O_NONBLOCK on open, the same descriptor validated as a regular file under the +// size cap, and a bounded read. +func readGuardedLines(absPath string) (map[string]frontmatter.Field, []string, bool) { none := map[string]frontmatter.Field{} f, err := os.OpenFile(absPath, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) if err != nil { - return none, fallbackTitle + return none, nil, false } defer f.Close() fi, err := f.Stat() if err != nil || !fi.Mode().IsRegular() || fi.Size() > maxRecordHeadBytes { - return none, fallbackTitle + return none, nil, false } data, err := io.ReadAll(io.LimitReader(f, maxRecordHeadBytes+1)) if err != nil || len(data) > maxRecordHeadBytes { - return none, fallbackTitle + return none, nil, false } lines := strings.Split(string(data), "\n") - fields := frontmatter.Fields(lines) - for _, ln := range lines { - if strings.HasPrefix(ln, "# ") { - return fields, strings.TrimSpace(strings.TrimPrefix(ln, "# ")) - } - } - return fields, fallbackTitle + return frontmatter.Fields(lines), lines, true } // firstLine returns the first non-blank line of body, whitespace-collapsed, diff --git a/internal/core/record/record_test.go b/internal/core/record/record_test.go index 75e3218cf..8e32b59f8 100644 --- a/internal/core/record/record_test.go +++ b/internal/core/record/record_test.go @@ -745,3 +745,97 @@ func TestDescribeSkippedIssueMatchesTheRosterByNumber(t *testing.T) { } }) } + +// The ledger families spc-2609020626040342 adds to the dispatcher: admissions +// and surprises. rfm is admitted by the gate here and described by the reframe +// spec that lands after; the reading families stay outside it. +func TestIDReAdmitsTheThreeNewFamilies(t *testing.T) { + for _, id := range []string{"adm-1", "srp-2609251200001234", "rfm-3"} { + if !IDRe.MatchString(id) { + t.Errorf("IDRe refuses %s", id) + } + } +} + +func TestIDReStillRefusesTheReadingFamilies(t *testing.T) { + for _, id := range []string{"rdi-1", "dsp-1", "rdg-1", "adm-", "ADM-1", "srp-1-slug"} { + if IDRe.MatchString(id) { + t.Errorf("IDRe admits %s, which stays outside the dispatcher", id) + } + } +} + +// admissionLedger lays out a widening item, its accepted disposition and one +// admission joining them, and returns the repo root. +func admissionLedger(t *testing.T) string { + t.Helper() + repo := t.TempDir() + ledger := filepath.FromSlash(capture.LedgerRelPath) + write(t, repo, filepath.Join(ledger, "readings", "rdg-7", "rdi-11.md"), + "---\nschema_version: 1\nid: \"rdi-11\"\nrun: \"rdg-7\"\nposition: \"widening\"\n---\n") + write(t, repo, filepath.Join(ledger, "dispositions", "rdi-11", "dsp-21.md"), + "---\nschema_version: 1\nid: \"dsp-21\"\nitem: \"rdi-11\"\nstate: \"accepted\"\ndisposition_grounds: \"the frame is engaged\"\n---\n") + write(t, repo, filepath.Join(ledger, "admissions", "rdg-7", "adm-31.md"), + "---\nschema_version: 1\nid: \"adm-31\"\nrun: \"rdg-7\"\nproposal: \"rdi-11\"\ngrounds: \"the frame is engaged\"\n---\n") + return repo +} + +// ac-8: `abcd adm-N` reports the record and the records it joins to — the run, +// the proposal and its path, and the standing disposition — and emits no next +// move. It writes nothing. +func TestDescribeAdmission(t *testing.T) { + repo := admissionLedger(t) + before := treeSnapshot(t, repo) + d, err := Describe(repo, "adm-31") + if err != nil { + t.Fatalf("Describe(adm-31): %v", err) + } + if d.Family != "admission" || d.Status != "admitted" || d.Title != "the frame is engaged" { + t.Fatalf("description = %+v", d) + } + if want := filepath.ToSlash(filepath.Join(capture.LedgerRelPath, "admissions", "rdg-7", "adm-31.md")); d.Path != want { + t.Errorf("path = %q, want %q", d.Path, want) + } + for k, want := range map[string]string{ + "run": "rdg-7", "proposal": "rdi-11", "disposition": "dsp-21", + "proposal_path": filepath.ToSlash(filepath.Join(capture.LedgerRelPath, "readings", "rdg-7", "rdi-11.md")), + } { + if d.Links[k] != want { + t.Errorf("links[%s] = %q, want %q", k, d.Links[k], want) + } + } + if len(d.NextMoves) != 0 { + t.Errorf("an admission emits no next move; got %v", d.NextMoves) + } + if _, err := Describe(repo, "adm-99"); err == nil || !strings.Contains(err.Error(), "adm-99") { + t.Errorf("an absent admission must fault naming it; got %v", err) + } + assertZeroWrites(t, repo, before) +} + +// ac-8's surprise half: `abcd srp-N` reports the surprise, its body as the +// title, and the occasion it joins to. +func TestDescribeSurprise(t *testing.T) { + repo := admissionLedger(t) + write(t, repo, filepath.Join(filepath.FromSlash(capture.LedgerRelPath), "surprises", "srp-41.md"), + "---\nschema_version: 1\nid: \"srp-41\"\noccasioned_by: \"adm-31\"\n---\n\nthe proposal nobody expected ranked first\n") + before := treeSnapshot(t, repo) + d, err := Describe(repo, "srp-41") + if err != nil { + t.Fatalf("Describe(srp-41): %v", err) + } + if d.Family != "surprise" || d.Status != "recorded" || d.Title != "the proposal nobody expected ranked first" { + t.Fatalf("description = %+v", d) + } + if d.Links["occasioned_by"] != "adm-31" || + d.Links["occasion_path"] != filepath.ToSlash(filepath.Join(capture.LedgerRelPath, "admissions", "rdg-7", "adm-31.md")) { + t.Errorf("links = %v", d.Links) + } + if len(d.NextMoves) != 0 { + t.Errorf("a surprise emits no next move; got %v", d.NextMoves) + } + if _, err := Describe(repo, "srp-99"); err == nil || !strings.Contains(err.Error(), "srp-99") { + t.Errorf("an absent surprise must fault naming it; got %v", err) + } + assertZeroWrites(t, repo, before) +} From a96657a922033cac2b5c3c3ed017e1e0d61b0e95 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:09:56 +0100 Subject: [PATCH 04/78] feat(lint): a closed surprise occasion and a per-run widening count record_schema holds a surprise's occasioned_by to the closed form the verb writes: a verbatim rdi-N, adm-N or dsp-N handle that resolves, so a prose occasion is a finding rather than a join that joins nothing. The generic join legs keep their tests on a join declaring no family. The outstanding report gains WideningRuns: per widening run, its proposals and how many were admitted, declined and held, and which carry neither an admission nor a declined or held disposition, rendered as one info finding per run. A run whose answers cannot all be read stands its count down. The unadmitted line names `capture admit`, and the duplicate-key reader table gains the record dispatcher as the surprise family's reader. Part of spc-2609020626040342. Assisted-by: Claude:claude-opus-5-5 --- .../core/lint/duplicatekeyreaders_test.go | 22 ++- .../core/lint/reading_outstanding_test.go | 173 +++++++++++++++--- internal/core/lint/readingoutstanding.go | 77 +++++++- internal/core/lint/schema.go | 72 ++++++-- internal/core/lint/schema_test.go | 128 ++++++++++--- 5 files changed, 405 insertions(+), 67 deletions(-) diff --git a/internal/core/lint/duplicatekeyreaders_test.go b/internal/core/lint/duplicatekeyreaders_test.go index ce0cb3c40..5dc968dda 100644 --- a/internal/core/lint/duplicatekeyreaders_test.go +++ b/internal/core/lint/duplicatekeyreaders_test.go @@ -112,7 +112,7 @@ func duplicateKeyReaderRows() []readerRow { probe: probeChangelogIssue, }, { store: "rdi", - reader: "capture.Disposition → readingItemPosition → parseFrontmatterAndBody", + reader: "capture.Disposition → readItemHead → parseFrontmatterAndBody", want: refuses, probe: probeReadingItemDisposition, }, { @@ -147,8 +147,8 @@ func duplicateKeyReaderRows() []readerRow { probe: probeAdmission, }, { store: "srp", - reader: "none", - want: unread, + reader: "record.Describe → describeSurprise → readRecordHeadAndBody → frontmatter.Fields", + want: keepsFirst, probe: probeSurprise, }} } @@ -488,8 +488,8 @@ func probeAdmission(t *testing.T) answer { return "" } -// probeReadingRun and probeSurprise bound an ABSENCE. No reader outside this rule -// opens either store's content, so there is nothing to exercise — what the probe +// probeReadingRun bounds an ABSENCE. No reader outside this rule opens the +// store's content, so there is nothing to exercise — what the probe // can do instead is run the readers that WALK a store unprompted over a corpus // holding the record, and show that its content reaches none of them. // @@ -507,9 +507,17 @@ func probeReadingRun(t *testing.T) answer { "---\nschema_version: 1\nid: rdg-1\nmanifest: FIRST-MARKER\nmanifest: SECOND-MARKER\n---\n\n") } +// probeSurprise reads a surprise through the record dispatcher, the one reader +// the family has (spc-2609020626040342): `abcd srp-N` renders its occasion. func probeSurprise(t *testing.T) answer { - return probeUnread(t, ".abcd/work/issues/surprises/srp-6.md", - "---\nschema_version: 1\nid: srp-6\noccasioned_by: FIRST-MARKER\noccasioned_by: SECOND-MARKER\n---\n\n") + root := t.TempDir() + writeRel(t, root, ".abcd/work/issues/surprises/srp-6.md", + "---\nschema_version: 1\nid: srp-6\noccasioned_by: FIRST-MARKER\noccasioned_by: SECOND-MARKER\n---\n\nunexpected\n") + d, err := record.Describe(root, "srp-6") + if err != nil { + return refuses + } + return which(t, d.Links["occasioned_by"], "FIRST-MARKER", "SECOND-MARKER") } func probeUnread(t *testing.T, rel, body string) answer { diff --git a/internal/core/lint/reading_outstanding_test.go b/internal/core/lint/reading_outstanding_test.go index 18e02cff0..32aefbf54 100644 --- a/internal/core/lint/reading_outstanding_test.go +++ b/internal/core/lint/reading_outstanding_test.go @@ -57,11 +57,11 @@ func TestOutstandingReportNamesUndispositionedItems(t *testing.T) { if err != nil { t.Fatal(err) } - if n := countRule(fs, ruleReadingOutstanding); n != 1 { + if n := countRule(answerLines(fs), ruleReadingOutstanding); n != 1 { t.Fatalf("expected exactly 1 %s finding, got %d: %+v", ruleReadingOutstanding, n, fs) } - if !strings.Contains(fs[0].Message, item) { - t.Fatalf("the report must name the item; got %q", fs[0].Message) + if !strings.Contains(answerLines(fs)[0].Message, item) { + t.Fatalf("the report must name the item; got %q", answerLines(fs)[0].Message) } // An answered item drops off the report — the status signal is the presence @@ -73,7 +73,7 @@ func TestOutstandingReportNamesUndispositionedItems(t *testing.T) { if err != nil { t.Fatal(err) } - if n := countRule(fs, ruleReadingOutstanding); n != 0 { + if n := countRule(answerLines(fs), ruleReadingOutstanding); n != 0 { t.Fatalf("a dispositioned item must not be outstanding, got %d finding(s): %+v", n, fs) } } @@ -118,10 +118,10 @@ func TestOpenHoldRendersItsExitCondition(t *testing.T) { if err != nil { t.Fatal(err) } - if n := countRule(fs, ruleReadingOutstanding); n != 1 { + if n := countRule(answerLines(fs), ruleReadingOutstanding); n != 1 { t.Fatalf("expected exactly 1 %s finding for the open hold, got %d: %+v", ruleReadingOutstanding, n, fs) } - msg := fs[0].Message + msg := answerLines(fs)[0].Message if !strings.Contains(msg, "held") || !strings.Contains(msg, "the closing run returns it again") { t.Fatalf("an open hold must render with its exit condition; got %q", msg) } @@ -136,7 +136,7 @@ func TestOpenHoldRendersItsExitCondition(t *testing.T) { if err != nil { t.Fatal(err) } - if n := countRule(fs, ruleReadingOutstanding); n != 0 { + if n := countRule(answerLines(fs), ruleReadingOutstanding); n != 0 { t.Fatalf("a superseded hold must leave the report, got %d finding(s): %+v", n, fs) } } @@ -446,11 +446,11 @@ func TestWideningProposalWithoutAdmissionOrDeclineIsOutstanding(t *testing.T) { if err != nil { t.Fatal(err) } - if n := countRule(fs, ruleReadingOutstanding); n != 1 { + if n := countRule(answerLines(fs), ruleReadingOutstanding); n != 1 { t.Fatalf("expected exactly 1 %s finding, got %d: %+v", ruleReadingOutstanding, n, fs) } - if !strings.Contains(fs[0].Message, item) || !strings.Contains(fs[0].Message, "admission") { - t.Fatalf("the report must name the proposal and the missing admission; got %q", fs[0].Message) + if !strings.Contains(answerLines(fs)[0].Message, item) || !strings.Contains(answerLines(fs)[0].Message, "admission") { + t.Fatalf("the report must name the proposal and the missing admission; got %q", answerLines(fs)[0].Message) } // And the admission answers it: the grounds are on the record, so there is @@ -460,7 +460,7 @@ func TestWideningProposalWithoutAdmissionOrDeclineIsOutstanding(t *testing.T) { if err != nil { t.Fatal(err) } - if n := countRule(fs, ruleReadingOutstanding); n != 0 { + if n := countRule(answerLines(fs), ruleReadingOutstanding); n != 0 { t.Fatalf("an admitted proposal must not be outstanding, got %d finding(s): %+v", n, fs) } } @@ -478,7 +478,7 @@ func TestDeclinedDispositionSatisfiesTheAdmissionLeg(t *testing.T) { if err != nil { t.Fatal(err) } - if n := countRule(fs, ruleReadingOutstanding); n != 0 { + if n := countRule(answerLines(fs), ruleReadingOutstanding); n != 0 { t.Fatalf("a declined proposal is answered, got %d finding(s): %+v", n, fs) } } @@ -495,7 +495,7 @@ func TestAdmissionRecordSatisfiesTheAdmissionLeg(t *testing.T) { if err != nil { t.Fatal(err) } - if n := countRule(fs, ruleReadingOutstanding); n != 0 { + if n := countRule(answerLines(fs), ruleReadingOutstanding); n != 0 { t.Fatalf("an admitted proposal must not be outstanding, got %d finding(s): %+v", n, fs) } @@ -511,7 +511,7 @@ func TestAdmissionRecordSatisfiesTheAdmissionLeg(t *testing.T) { if err != nil { t.Fatal(err) } - if n := countRule(fs, ruleReadingOutstanding); n != 1 { + if n := countRule(answerLines(fs), ruleReadingOutstanding); n != 1 { t.Fatalf("an undispositioned detection is still outstanding, got %d finding(s): %+v", n, fs) } } @@ -558,7 +558,7 @@ func TestAdmissionFilenameGrammarMatchesTheGate(t *testing.T) { if err != nil { t.Fatal(err) } - if n := countRule(fs, ruleReadingOutstanding); n != 0 { + if n := countRule(answerLines(fs), ruleReadingOutstanding); n != 0 { t.Fatalf("an admission carrying a slug in its filename must still admit its proposal, got %d finding(s): %+v", n, fs) } } @@ -633,7 +633,7 @@ func TestAnAdmissionAdmitsOnlyWithinItsOwnRun(t *testing.T) { t.Errorf("%s carries no admission in its own run and must still be reported: %+v", item, fs) } } - if n := countRule(fs, ruleReadingOutstanding); n != 2 { + if n := countRule(answerLines(fs), ruleReadingOutstanding); n != 2 { t.Fatalf("expected both proposals outstanding, got %d finding(s): %+v", n, fs) } } @@ -650,7 +650,7 @@ func TestAnAdmissionInItsOwnRunStillAdmits(t *testing.T) { if err != nil { t.Fatal(err) } - if n := countRule(fs, ruleReadingOutstanding); n != 0 { + if n := countRule(answerLines(fs), ruleReadingOutstanding); n != 0 { t.Fatalf("an admission under its proposal's own run must admit it, got %d finding(s): %+v", n, fs) } } @@ -672,7 +672,7 @@ func TestAnAdmissionWhoseRunFieldContradictsItsBucketAdmitsNothing(t *testing.T) if err != nil { t.Fatal(err) } - if n := countRule(fs, ruleReadingOutstanding); n != 1 { + if n := countRule(answerLines(fs), ruleReadingOutstanding); n != 1 { t.Fatalf("an admission contradicting its own bucket admits nothing, got %d finding(s): %+v", n, fs) } } @@ -761,11 +761,11 @@ func TestAHeldWideningProposalIsNotAlsoReportedUnadmitted(t *testing.T) { t.Errorf("a held proposal is already published with its exit condition: %s", f.Message) } } - if n := countRule(fs, ruleReadingOutstanding); n != 1 { + if n := countRule(answerLines(fs), ruleReadingOutstanding); n != 1 { t.Fatalf("expected exactly the open-hold line, got %d finding(s): %+v", n, fs) } - if !strings.Contains(fs[0].Message, "exit condition") { - t.Fatalf("the one line must be the hold and its exit condition; got %q", fs[0].Message) + if !strings.Contains(answerLines(fs)[0].Message, "exit condition") { + t.Fatalf("the one line must be the hold and its exit condition; got %q", answerLines(fs)[0].Message) } } @@ -1081,3 +1081,134 @@ func TestAnAdmissionNamingNoProposalIsKeyedOnNothing(t *testing.T) { t.Fatalf("an admission that names its proposal is keyed on it, got %+v", tree.admitted) } } + +// wideningItem writes one more widening item into run. +func wideningItem(t *testing.T, root, run, item string) { + t.Helper() + writeFile(t, root, ".abcd/work/issues/readings/"+run+"/"+item+".md", + "---\nschema_version: 1\nid: \""+item+"\"\nrun: \""+run+"\"\nmanifest: \"sha256:beef\"\n"+ + "position: \"widening\"\nregime: \""+issueschema.ReadingRegime("widening")+"\"\npattern: \"a stated constraint\"\n---\n\n") +} + +// ac-7 (spc-2609020626040342): a run of four widening items — one admitted, one +// declined, one held and one untouched — summarises as a count, and names the +// fourth as outstanding and no other. The admitted-against-declined count is a +// query, not an inspection. +func TestWideningRunSummaryNamesTheOutstandingItem(t *testing.T) { + const run = "rdg-2608300000000001" + admitted, declined, held, untouched := "rdi-2608300000000011", "rdi-2608300000000012", + "rdi-2608300000000013", "rdi-2608300000000014" + root := readingLedger(t, run, admitted, "widening") + for _, item := range []string{declined, held, untouched} { + wideningItem(t, root, run, item) + } + dispositionRecord(t, root, admitted, "dsp-2608300000000021", issueschema.DispositionAccepted) + admissionRecord(t, root, run, "adm-2608300000000031", admitted) + dispositionRecord(t, root, declined, "dsp-2608300000000022", issueschema.DispositionDeclined) + writeFile(t, root, ".abcd/work/issues/dispositions/"+held+"/dsp-2608300000000023.md", + "---\nschema_version: 1\nid: \"dsp-2608300000000023\"\nitem: \""+held+"\"\n"+ + "state: \"held\"\nexit_condition: \"the next widening run returns it\"\n---\n\n") + // A detection item in the same repository is no part of any widening summary. + writeFile(t, root, ".abcd/work/issues/readings/rdg-2608300000000009/rdi-2608300000000099.md", + "---\nschema_version: 1\nid: \"rdi-2608300000000099\"\nrun: \"rdg-2608300000000009\"\nmanifest: \"sha256:beef\"\n"+ + "position: \"detection\"\nregime: \"registrative\"\npattern: \"a stated constraint\"\n---\n\n") + + report, err := ReadReadingOutstanding(root, ".abcd/work/issues") + if err != nil { + t.Fatal(err) + } + want := []WideningRun{{Run: run, Items: 4, Admitted: 1, Declined: 1, Held: 1, Outstanding: []string{untouched}}} + if !reflect.DeepEqual(report.WideningRuns, want) { + t.Fatalf("WideningRuns = %+v, want %+v", report.WideningRuns, want) + } + + fs, err := Lint(readingOutstandingConfig(severityBlocker), root) + if err != nil { + t.Fatal(err) + } + var summary []Finding + for _, f := range fs { + if f.RuleID == ruleReadingOutstanding && strings.Contains(f.Message, "widening proposal(s)") { + summary = append(summary, f) + } + } + if len(summary) != 1 { + t.Fatalf("want one summary finding for the one widening run, got %d: %+v", len(summary), fs) + } + for _, w := range []string{run, "1 admitted", "1 declined", "1 held", untouched} { + if !strings.Contains(summary[0].Message, w) { + t.Errorf("the summary must say %q; got %q", w, summary[0].Message) + } + } + for _, other := range []string{admitted, declined, held} { + if strings.Contains(summary[0].Message, other) { + t.Errorf("the summary names %s, which is answered: %q", other, summary[0].Message) + } + } +} + +// A run whose admissions cannot be read supports no count: the summary stands +// down for that run rather than calling every proposal outstanding, and the +// Unsafe line names why. Another run's summary is unaffected. +func TestWideningRunSummaryStandsDownOnAnUnreadableRun(t *testing.T) { + const run, other = "rdg-2608300000000001", "rdg-2608300000000002" + root := readingLedger(t, run, "rdi-2608300000000011", "widening") + wideningItem(t, root, other, "rdi-2608300000000021") + link := filepath.Join(root, filepath.FromSlash(".abcd/work/issues/admissions/"+run)) + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + report, err := ReadReadingOutstanding(root, ".abcd/work/issues") + if err != nil { + t.Fatal(err) + } + want := []WideningRun{{Run: other, Items: 1, Outstanding: []string{"rdi-2608300000000021"}}} + if !reflect.DeepEqual(report.WideningRuns, want) { + t.Fatalf("WideningRuns = %+v, want only %s's summary", report.WideningRuns, other) + } + if len(report.Unsafe) == 0 { + t.Fatal("the unreadable admissions run must be named") + } +} + +// The summary is a report line, pinned at info whatever the configuration asks. +func TestWideningRunSummaryIsInfoNotBlocker(t *testing.T) { + root := readingLedger(t, "rdg-2608300000000001", "rdi-2608300000000011", "widening") + dispositionRecord(t, root, "rdi-2608300000000011", "dsp-2608300000000021", issueschema.DispositionDeclined) + fs, err := Lint(readingOutstandingConfig(severityBlocker), root) + if err != nil { + t.Fatal(err) + } + var seen bool + for _, f := range fs { + if f.RuleID != ruleReadingOutstanding { + continue + } + if strings.Contains(f.Message, "widening proposal(s)") { + seen = true + } + if f.Severity != severityInfo { + t.Fatalf("severity = %q, want %q", f.Severity, severityInfo) + } + } + if !seen { + t.Fatalf("a fully answered widening run still carries its summary line: %+v", fs) + } +} + +// answerLines drops the per-run widening summaries (spc-2609020626040342) from a +// finding list, leaving the lines that are about one item's answer — which is +// what every count in this file before the summary landed is counting. +func answerLines(fs []Finding) []Finding { + var out []Finding + for _, f := range fs { + if f.RuleID == ruleReadingOutstanding && strings.Contains(f.Message, "widening proposal(s) —") { + continue + } + out = append(out, f) + } + return out +} diff --git a/internal/core/lint/readingoutstanding.go b/internal/core/lint/readingoutstanding.go index 644d90831..b147be7d0 100644 --- a/internal/core/lint/readingoutstanding.go +++ b/internal/core/lint/readingoutstanding.go @@ -123,6 +123,28 @@ type OpenHold struct { Path string `json:"path"` } +// WideningRun is one widening run's answer count (spc-2609020626040342): how many +// proposals it returned, how many were admitted, declined and held, and which +// carry neither an admission nor a `declined` or `held` disposition. It makes +// the admitted-against-declined count — the evidence that the admission +// asymmetry is being exercised — a query rather than an inspection. +// +// It is reported only for a run whose every widening item's answer the walk +// could read without ambiguity. A run holding an unreadable item, an unreadable +// admission, an answer the walk declined to read, or a contested, cyclic or +// illegible disposition supports no count, so its summary stands down; the +// report's other lists already name what stood in the way. +type WideningRun struct { + Run string `json:"run"` + Items int `json:"items"` + Admitted int `json:"admitted"` + Declined int `json:"declined"` + Held int `json:"held"` + // Outstanding names the proposals carrying neither an admission nor a + // `declined` or `held` disposition, sorted. Never null. + Outstanding []string `json:"outstanding"` +} + // OutstandingReadings is the whole report, ordered deterministically. type OutstandingReadings struct { Undispositioned []OutstandingItem `json:"undispositioned"` @@ -167,6 +189,9 @@ type OutstandingReadings struct { // and the item simply vanished from the board — and an item whose only answer // is unreadable is the case most in need of a line, not least. Unreadable []UnreadableAnswer `json:"unreadable,omitempty"` + // WideningRuns is the per-run answer count of every widening run whose + // answers the walk could read, ordered by run. + WideningRuns []WideningRun `json:"widening_runs,omitempty"` } // UnsafePath is one path the walk declined to read, with the reason it declined. @@ -202,7 +227,7 @@ type ContestedItem struct { func (r OutstandingReadings) Empty() bool { return len(r.Undispositioned) == 0 && len(r.Unadmitted) == 0 && len(r.OpenHolds) == 0 && len(r.Unsafe) == 0 && len(r.Cyclic) == 0 && len(r.Contested) == 0 && - len(r.Unreadable) == 0 + len(r.Unreadable) == 0 && len(r.WideningRuns) == 0 } // ReadReadingOutstanding builds the report from the ledger at issuesDir @@ -263,6 +288,11 @@ func ReadReadingOutstanding(repoRoot, issuesDir string) (OutstandingReadings, er if err != nil { return OutstandingReadings{}, err } + // The run's widening summary. It stands down — withheld, not zeroed — + // the moment any fact it would count cannot be read, because a count + // over a partial read is a confident false statement about the run. + summary := WideningRun{Run: run.Name(), Outstanding: []string{}} + standDown := !dispositionsReadable || admissions.unknown(run.Name()) for _, e := range entries { m := readingItemFileRe.FindStringSubmatch(e.Name()) if e.IsDir() || m == nil { @@ -284,9 +314,16 @@ func ReadReadingOutstanding(repoRoot, issuesDir string) (OutstandingReadings, er report.Unsafe = append(report.Unsafe, UnsafePath{ Path: filepath.ToSlash(rel), Reason: unreadableReason(rerr), }) + // An item nobody could read may be a widening proposal, so the + // run's count is not known. + standDown = true continue } position := readingPosition(string(content)) + widening := position == issueschema.PositionWidening + if widening { + summary.Items++ + } if !dispositionsReadable { // The item's answer is unreadable, which is not the same fact as // "unanswered" — reporting it outstanding would be a confident @@ -378,6 +415,26 @@ func ReadReadingOutstanding(repoRoot, issuesDir string) (OutstandingReadings, er // because a hold is not an alternative to the facts above; it is an // additional one. report.OpenHolds = append(report.OpenHolds, answer.holds...) + + if widening { + switch { + case admissions.admits(run.Name(), item): + summary.Admitted++ + case len(answer.unsafe) > 0 || answer.cyclic || len(answer.contested) > 1 || + (answer.standing != nil && !answer.standing.wellFormed): + standDown = true + case answer.standing != nil && answer.standing.state == issueschema.DispositionDeclined: + summary.Declined++ + case answer.standing != nil && answer.standing.state == issueschema.DispositionHeld: + summary.Held++ + default: + summary.Outstanding = append(summary.Outstanding, item) + } + } + } + if summary.Items > 0 && !standDown { + sort.Strings(summary.Outstanding) + report.WideningRuns = append(report.WideningRuns, summary) } } @@ -394,6 +451,7 @@ func ReadReadingOutstanding(repoRoot, issuesDir string) (OutstandingReadings, er sort.Slice(report.Contested, func(i, j int) bool { return report.Contested[i].Item < report.Contested[j].Item }) sort.Slice(report.Unreadable, func(i, j int) bool { return report.Unreadable[i].Item < report.Unreadable[j].Item }) sort.Slice(report.Unsafe, func(i, j int) bool { return report.Unsafe[i].Path < report.Unsafe[j].Path }) + sort.Slice(report.WideningRuns, func(i, j int) bool { return report.WideningRuns[i].Run < report.WideningRuns[j].Run }) return report, nil } @@ -674,10 +732,25 @@ func checkReadingOutstanding(repoRoot string, cfg RuleConfig) ([]Finding, error) Message: o.Item + " (run " + o.Run + ") is a widening proposal with neither an admission nor a decline — outstanding. " + "At the widening position acceptance IS admission, and the grounds an admission was made on live in an " + "admission record (`" + issueschema.AdmissionFamily + "-N` under " + issueschema.AdmissionsDir + "/" + o.Run + - "/), because uniform adoption of everything a reading proposes is equally consistent with judgement and with abdication. " + + "/), because uniform adoption of everything a reading proposes is equally consistent with judgement and with abdication; " + + "write it with `abcd capture admit " + o.Item + " --grounds \"\"`, which on a standing acceptance writes the admission alone. " + "Declining costs nothing epistemically and is recorded as a disposition in the `" + issueschema.DispositionDeclined + "` state", }) } + for _, w := range report.WideningRuns { + msg := w.Run + ": " + strconv.Itoa(w.Items) + " widening proposal(s) — " + + strconv.Itoa(w.Admitted) + " admitted, " + strconv.Itoa(w.Declined) + " declined, " + + strconv.Itoa(w.Held) + " held, " + strconv.Itoa(len(w.Outstanding)) + " outstanding" + if len(w.Outstanding) > 0 { + msg += " (" + strings.Join(w.Outstanding, ", ") + "): an outstanding proposal carries neither an admission nor a `" + + issueschema.DispositionDeclined + "` or `" + issueschema.DispositionHeld + "` disposition; admit it with `abcd capture admit --grounds \"\"` " + + "or decline it with `abcd capture disposition --state " + issueschema.DispositionDeclined + " --grounds \"\"`" + } + out = append(out, Finding{ + File: filepath.ToSlash(filepath.Join(issuesDirOf(cfg), issueschema.ReadingsDir, w.Run)), Line: 1, + RuleID: ruleReadingOutstanding, Severity: severityInfo, Message: msg, + }) + } for _, u := range report.Unreadable { out = append(out, Finding{ File: u.Path, Line: 1, RuleID: ruleReadingOutstanding, Severity: severityInfo, diff --git a/internal/core/lint/schema.go b/internal/core/lint/schema.go index fd8a2e087..f94fa5009 100644 --- a/internal/core/lint/schema.go +++ b/internal/core/lint/schema.go @@ -200,11 +200,12 @@ type recordStore struct { // all three legs made the ADR store claim a refusal nobody performs // (iss-2608301656200729). That leg reads readerRefusesDuplicateKey instead. // - // The two stores this cycle added have no such reader: the only reader of - // admission records honours one carrying nothing but its run and its proposal - // (reading_outstanding_test.go), and no reader of surprise records exists at - // all — so a message telling their authors the record is skipped and invisible - // sends them to look for a refusal nobody performs (iss-2608301411010342). + // The two stores this cycle added have no such reader: the outstanding report + // honours an admission carrying nothing but its run and its proposal + // (reading_outstanding_test.go), and the record dispatcher reads both families + // leniently and skips neither — so a message telling their authors the record + // is skipped and invisible sends them to look for a refusal nobody performs + // (iss-2608301411010342). // Where it is false each leg states what the malformation IS, which is true of // every store, and stops there. readerFailsClosed bool @@ -304,6 +305,18 @@ type recordJoin struct { // join that ALSO declares sameBucketAs; declared alone it would be inert, which // TestEveryJoinTargetPositionIsADeclaredPosition refuses. targetPosition string + // oneOf names the CLOSED set of families this join's value must be a handle + // of, verbatim, for a join whose value may name one of several families. + // Empty means the join declares no such set. + // + // The surprise's `occasioned_by` is the one such join: an rdi-N, adm-N or + // dsp-N and nothing else (spc-2609020626040342). It used to admit a + // consequence named in prose, so a prose value passed the gate while the + // surprise verb refuses it — a hand-written record joined to nothing. The set + // is issueschema's one declaration (SurpriseOccasionFamilies), so the verb and + // this gate cannot disagree about it. A value in the set then resolves on the + // ordinary presence leg below. + oneOf []string } // bucketed reports whether the store holds its records in lifecycle @@ -407,7 +420,8 @@ var recordStores = []recordStore{ requiredFields: issueschema.SurpriseRequired, knownFields: issueschema.SurpriseKnown, joins: []recordJoin{{ field: "occasioned_by", - why: "a surprise is keyed to whatever occasioned it, and a join naming nothing joins nothing", + why: "a surprise is keyed to the record that occasioned it, and a join naming nothing joins nothing", + oneOf: issueschema.SurpriseOccasionFamilies, }}}, } @@ -852,8 +866,8 @@ func checkRecordRequiredFields(r schemaRecord, severity string, judged map[strin // — invisible to every surface of its own family while it still sits in the store. // That second account is gated on readerFailsClosed for the reason the // missing-property account is: the admission reader COUNTS a record carrying an -// unknown key, and no reader of surprise records exists at all -// (iss-2608301519254418). +// unknown key, and the one reader of surprise records — the record dispatcher — +// reads them leniently and skips none (iss-2608301519254418). func checkRecordUnknownFields(r schemaRecord, severity string) []Finding { if r.store.knownFields == nil { return nil @@ -936,10 +950,10 @@ func checkRecordUnknownFields(r schemaRecord, severity string) []Finding { // queries. It is declared per join AND per target family (sameBucketAs), because // that pair-keying is a property of the family and the message names it. // -// Prose is legitimate and stays silent WHERE THE JOIN DECLARES NO FAMILY. A -// surprise is keyed to whatever occasioned it — a detection, an admission, or a -// consequence that has no id — so only a value that is a record handle of a store -// this scan reads is resolved. A handle a record declares it PRUNED is resolved +// Prose is legitimate and stays silent WHERE THE JOIN DECLARES NO FAMILY AND NO +// CLOSED SET: only a value that is a record handle of a store this scan reads is +// resolved. A surprise's occasion is NOT such a join — it declares its closed set +// (oneOf), so a prose occasion is a finding (spc-2609020626040342). A handle a record declares it PRUNED is resolved // too, on the same terms the cross-reference loop resolves it, so one rule gives // one answer about it. func checkRecordJoins(r schemaRecord, index map[recordRef]schemaRecord, retired map[recordRef]bool, cfg RuleConfig) []Finding { @@ -976,6 +990,17 @@ func checkRecordJoins(r schemaRecord, index map[recordRef]schemaRecord, retired }) continue } + // The closed set, where the join declares one: the value must be verbatim a + // handle of one of its families, so prose and a fourth family are findings + // rather than the silence an undeclared join gives them. + if len(join.oneOf) > 0 && !spellsHandleOfAny(join.oneOf, value) { + out = append(out, Finding{ + File: r.rel, Line: line, RuleID: ruleRecordSchema, Severity: cfg.Severity, + Message: join.field + " declares '" + value + "', which is not a handle of " + handleList(join.oneOf) + + " (lower case with nothing around it); " + join.why, + }) + continue + } m := anyHandleFullRe.FindStringSubmatch(value) if m == nil { continue @@ -1119,6 +1144,26 @@ func spellsHandleOf(family, value string) bool { return true } +// spellsHandleOfAny reports whether value is verbatim a handle of one of +// families. +func spellsHandleOfAny(families []string, value string) bool { + for _, f := range families { + if spellsHandleOf(f, value) { + return true + } + } + return false +} + +// handleList renders a family set as the handles a message names. +func handleList(families []string) string { + names := make([]string, 0, len(families)) + for _, f := range families { + names = append(names, f+"-") + } + return strings.Join(names, ", ") +} + // joinFamilyNoun renders the record kind a join's declared family holds, for the // message that names it. Every declared family is one of recordStores' own // prefixes, which TestEveryJoinFamilyNamesADeclaredStore pins. @@ -1495,7 +1540,8 @@ func scanRecordStores(repoRoot string, cfg RuleConfig) ([]schemaRecord, []Findin // The refusal half is gated on readerRefusesDuplicateKey rather than on // readerFailsClosed, because the two come apart on this malformation // alone: the admission reader COUNTS a record carrying a duplicated key, - // no reader of surprise records exists, and the ADR dispatcher — which + // the record dispatcher reads surprise records with the lenient scanner + // on its first value, and the ADR dispatcher — which // does validate the id — reads the frontmatter with the lenient scanner // and never sees the second line, so naming a refusal on any of the three // sends the author looking for one nobody performs (iss-2608301519254418, diff --git a/internal/core/lint/schema_test.go b/internal/core/lint/schema_test.go index 79c0dd72d..7523e65a9 100644 --- a/internal/core/lint/schema_test.go +++ b/internal/core/lint/schema_test.go @@ -1,6 +1,7 @@ package lint import ( + "fmt" "path/filepath" "strings" "testing" @@ -1111,10 +1112,9 @@ func TestAdmissionStoreBucketsByRun(t *testing.T) { // the standing-disposition reader, and a disposition in the surprise store would // be an answer nobody reads. func TestSurpriseRecordIsNotADisposition(t *testing.T) { - root := t.TempDir() - writeFile(t, root, "rec/.keep", "") + root := admissionCorpus(t) writeFile(t, root, "work/issues/surprises/srp-4.md", - "---\nschema_version: 1\nid: srp-4\noccasioned_by: a consequence nobody predicted\n---\n\n") + "---\nschema_version: 1\nid: srp-4\noccasioned_by: rdi-2\n---\n\n") writeFile(t, root, "work/issues/dispositions/rdi-2/srp-5.md", "---\nschema_version: 1\nid: srp-5\noccasioned_by: a consequence nobody predicted\n---\n\n") writeFile(t, root, "work/issues/surprises/dsp-6.md", @@ -1137,12 +1137,11 @@ func TestSurpriseRecordIsNotADisposition(t *testing.T) { } } -// occasioned_by is the surprise's whole join. Where it names a RECORD, that -// record must be in the corpus: a join naming nothing joins nothing, and the +// occasioned_by is the surprise's whole join, and it names a RECORD: that record +// must be in the corpus, because a join naming nothing joins nothing and the // surprise then sits beside the thing it claims to have arisen from with no way -// back to it. Prose naming a consequence is legitimate and stays silent — a -// surprise is keyed to whatever occasioned it, and not everything that occasions -// one has an id. +// back to it. Prose is not an occasion — the form is closed to rdi-N, adm-N and +// dsp-N (spc-2609020626040342) — so a prose value is a finding too. func TestSurpriseOccasionedByResolves(t *testing.T) { root := t.TempDir() writeFile(t, root, "rec/.keep", "") @@ -1164,8 +1163,11 @@ func TestSurpriseOccasionedByResolves(t *testing.T) { if !findingWith(fs, filepath.Join("work", "issues", "surprises", "srp-6.md"), ruleRecordSchema, "rdi-9999") { t.Errorf("an occasioned_by naming no record in the corpus must be a finding: %+v", fs) } - if n := countRule(fs, ruleRecordSchema); n != 1 { - t.Fatalf("expected exactly 1 finding (the dangling join), got %d: %+v", n, fs) + if !findingWith(fs, filepath.Join("work", "issues", "surprises", "srp-5.md"), ruleRecordSchema, "not a handle of") { + t.Errorf("a prose occasioned_by must be a finding: %+v", fs) + } + if n := countRule(fs, ruleRecordSchema); n != 2 { + t.Fatalf("expected exactly 2 findings (the dangling join and the prose occasion), got %d: %+v", n, fs) } } @@ -1594,7 +1596,13 @@ func TestBucketJoinBlockerAssertsNoIDCollision(t *testing.T) { // than to a file — which the cross-reference loop has always read that way. The // join check did not, so `related_adrs: [adr-5]` was accepted on one record while // `occasioned_by: adr-5` was a blocker on the next (iss-2608301327012166). +// +// No production join can name an ADR any more — the surprise's occasion is a +// closed set (spc-2609020626040342) — but the resolution leg is generic, so it is +// exercised on a join declaring no family and no closed set, the shape it was +// built on. func TestJoinsResolveARetiredHandleTheWayCrossReferencesDo(t *testing.T) { + withOpenSurpriseJoin(t) root := admissionCorpus(t) // adr-25 declares it replaced adr-5, which is therefore pruned rather than // missing. Its ordinal also puts adr-5 below the store's high-water mark, so @@ -1913,32 +1921,28 @@ func TestClosedSchemaAndDuplicateKeyClaimNoReaderWhereTheStoreHasNone(t *testing } } -// A join names a family this scan does not read, in both the ways a value can: -// a prefix no store declares at all, and a store this configuration does not -// point at. Neither supports a verdict — the record might be perfectly present in -// a store nobody configured — so reporting it missing would be a confident false -// statement, and `occasioned_by`, which declares no family, keeps the prose -// tolerance its leg is built on. +// A join names a family this scan does not read. That supports no verdict — +// the record might be perfectly present in a store nobody configured — so +// reporting it missing would be a confident false statement. // // The stand-down was correct code no test killed: deleting it left the suite // green while `occasioned_by: spike-3` drew a blocker saying it is not a record -// in the corpus (iss-2608301519254240). +// in the corpus (iss-2608301519254240). The surprise's occasion is a closed set +// now (spc-2609020626040342), so the stand-down is reached through a family in +// that set whose store this configuration does not point at. // -// It is killed as a WHOLE, and the two halves are not separably killable. A -// configuration naming a store no prefix declares is refused by LoadConfig's +// A configuration naming a store no prefix declares is refused by LoadConfig's // validateRecordStores, so for any configuration a production caller can hold, an // unread family is also an unknown one: the `!known` half is defence against a -// hand-built Config alone, and deleting it on its own leaves this test green. +// hand-built Config alone, and is exercised below on a join declaring no family. func TestAJoinIsSilentOnAFamilyThisScanDoesNotRead(t *testing.T) { root := admissionCorpus(t) - writeFile(t, root, "work/issues/surprises/srp-4.md", - "---\nschema_version: 1\nid: srp-4\noccasioned_by: spike-3\n---\n\n") writeFile(t, root, "work/issues/surprises/srp-5.md", - "---\nschema_version: 1\nid: srp-5\noccasioned_by: adr-9999\n---\n\n") + "---\nschema_version: 1\nid: srp-5\noccasioned_by: adm-9999\n---\n\n") cfg := admissionSchemaConfig() rule := cfg.Rules[ruleRecordSchema] - delete(rule.RecordStores, "adr") + delete(rule.RecordStores, "adm") cfg.Rules[ruleRecordSchema] = rule fs, err := Lint(cfg, root) @@ -1948,6 +1952,42 @@ func TestAJoinIsSilentOnAFamilyThisScanDoesNotRead(t *testing.T) { if n := countRule(fs, ruleRecordSchema); n != 0 { t.Fatalf("a family this scan does not read supports no verdict either way, got %d finding(s): %+v", n, fs) } + + t.Run("a prefix no store declares, on a join declaring no family", func(t *testing.T) { + withOpenSurpriseJoin(t) + root := admissionCorpus(t) + writeFile(t, root, "work/issues/surprises/srp-4.md", + "---\nschema_version: 1\nid: srp-4\noccasioned_by: spike-3\n---\n\n") + fs, err := Lint(admissionSchemaConfig(), root) + if err != nil { + t.Fatal(err) + } + if n := countRule(fs, ruleRecordSchema); n != 0 { + t.Fatalf("a prefix no store declares supports no verdict, got %d finding(s): %+v", n, fs) + } + }) +} + +// withOpenSurpriseJoin swaps the surprise store's join, for one test, for the +// shape the generic join legs were built on: a join declaring no family and no +// closed set, whose value may be prose or a handle of any store. No production +// store declares that shape any more (spc-2609020626040342), but the legs are +// store-declared and generic, so they are kept honest on it. +func withOpenSurpriseJoin(t *testing.T) { + t.Helper() + for i := range recordStores { + if recordStores[i].prefix != "srp" { + continue + } + orig := recordStores[i].joins + recordStores[i].joins = []recordJoin{{ + field: "occasioned_by", + why: "a surprise is keyed to whatever occasioned it, and a join naming nothing joins nothing", + }} + t.Cleanup(func() { recordStores[i].joins = orig }) + return + } + t.Fatal("no srp store") } // The reader of the family keys on the FILENAME, not on the record's `id` @@ -2677,3 +2717,43 @@ func TestRetiredPromoteStampIsNamedWithItsMigration(t *testing.T) { t.Fatalf("a retired promoted_to must be a finding naming its successor and the migration: %+v", fs) } } + +// TestSurpriseOccasionMustResolve: a surprise's `occasioned_by` is a CLOSED form +// (spc-2609020626040342) — an rdi-N, adm-N or dsp-N handle that resolves in the +// corpus, and nothing else. A prose occasion, a handle of a fourth family, and a +// handle naming no record are findings; each of the three families resolving is +// green. +func TestSurpriseOccasionMustResolve(t *testing.T) { + root := admissionCorpus(t) + writeFile(t, root, "work/issues/dispositions/rdi-2/dsp-3.md", + "---\nschema_version: 1\nid: dsp-3\nitem: rdi-2\nstate: accepted\ndisposition_grounds: worth acting on\n---\n\n") + writeFile(t, root, "work/issues/admissions/rdg-1/adm-2.md", wellFormedAdmission) + for i, occ := range []string{"rdi-2", "adm-2", "dsp-3"} { + writeFile(t, root, fmt.Sprintf("work/issues/surprises/srp-%d.md", 10+i), + fmt.Sprintf("---\nschema_version: 1\nid: srp-%d\noccasioned_by: %s\n---\n\nsomething unexpected\n", 10+i, occ)) + } + bad := map[string]string{ + "srp-20": "a consequence nobody predicted", + "srp-21": "itd-7", + "srp-22": "RDI-2", + "srp-23": "rdi-9999", + "srp-24": "adm-9999", + } + for id, occ := range bad { + writeFile(t, root, "work/issues/surprises/"+id+".md", + "---\nschema_version: 1\nid: "+id+"\noccasioned_by: "+occ+"\n---\n\nsomething unexpected\n") + } + + fs, err := Lint(admissionSchemaConfig(), root) + if err != nil { + t.Fatal(err) + } + for id, occ := range bad { + if !findingWith(fs, filepath.Join("work", "issues", "surprises", id+".md"), ruleRecordSchema, occ) { + t.Errorf("occasioned_by %q must be a finding naming it: %+v", occ, fs) + } + } + if n := countRule(fs, ruleRecordSchema); n != len(bad) { + t.Fatalf("expected exactly %d findings (the bad occasions), got %d: %+v", len(bad), n, fs) + } +} From a43dbc949d6b5386d7606704751e657e12865c29 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:10:03 +0100 Subject: [PATCH 05/78] feat(cli): capture admit and capture surprise front doors `capture admit --grounds ""` and `capture surprise --occasioned-by ""` register on the disposition verb's shape: no cobra-required flag beyond the surprise's occasion, the core refusing an empty value, --json inherited, renders sanitised and `redacted` reported when non-zero. The bare board renders each widening run's count and carries `widening_runs` in its JSON; the unadmitted line names the admit remedy. The plugin capture page documents both verbs and the ordering gate and drops its written-by-hand paragraph; the abcd page's dispatch grammar names the seven families. The capture and abcd brief chapters, the surface snapshot and the CLI reference move with the surface. Part of spc-2609020626040342. Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 83 ++++++++-- .../development/brief/04-surfaces/08-abcd.md | 10 +- .abcd/development/release/surface.json | 26 ++++ commands/abcd.md | 11 +- commands/capture.md | 79 ++++++++-- docs/reference/cli/commands.md | 24 +++ internal/surface/cli/capture_admit_test.go | 147 ++++++++++++++++++ internal/surface/cli/capture_root_test.go | 31 ++++ internal/surface/cli/cli.go | 92 ++++++++++- 9 files changed, 469 insertions(+), 34 deletions(-) create mode 100644 internal/surface/cli/capture_admit_test.go diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index 1705aefa8..d672c665d 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -24,6 +24,7 @@ binary. | Verb | Bucket | Status | |---|---|---| +| `admit` | — | shipped | | `defer` | — | shipped | | `disposition` | — | shipped | | `link` | — | shipped | @@ -32,6 +33,7 @@ binary. | `migrate` | — | shipped | | `promote` | — | shipped | | `resolve` | — | shipped | +| `surprise` | — | shipped | | `wontfix` | — | shipped | @@ -165,6 +167,37 @@ 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 populated value is refused until activation is ruled. +**At the widening position the order is fixed: characterise first, admit +second** (itd-2609020625400194, spc-2609020626040342). No disposition in any +state, and no admission, is written for a widening item until a committed +comparative run names the item's run; a comparative run committed with an empty +item set, the position not exercised, satisfies this as a characterising run +does. The refusal names the run it is waiting on. It is one gate in the one +disposition writer every verb routes through, so the disposition verb, the +admission verb and a scribe's ingest all refuse the same way. The other +positions are answered with no comparative run anywhere. + +**Admitting** a widening proposal is one act that writes two records under the +ledger lock: the item's `accepted` disposition and the admission record +(`adm-N`, under `admissions//`) that joins it to its run's candidate +set, both carrying the one ground the verb was given. Where an `accepted` +disposition already stands, the admission is written alone, and only on the +ground that disposition states. The ground is free text held to the same +substance floor as every grounds primitive. Everything else is refused with +nothing written: an item at any other position, an item already admitted, a +standing disposition in any other state (named with its state), a contested or +cyclic disposition set, a blank or degenerate ground, and any admission before +characterisation. If the admission fails to write, the disposition this act +wrote is removed. + +**Recording a surprise** writes one surprise entry (`srp-N`, under +`surprises/`) as its own record, the surprise itself as its body and +`occasioned_by` as its whole join. The occasion is a reading item, an admission +or a disposition this ledger holds, and nothing else: prose, a record of any +other family, and a handle naming nothing are refused before anything is +minted. No disposition is written on this path. The record gate holds a +hand-written surprise to the same closed form. + **Resolving** marks an issue resolved and moves it to `resolved/`. Impact is required, and resolving without it is refused with nothing written; grounds are recorded when given, their absence parked by @@ -361,6 +394,20 @@ for ad-hoc scribbles. unless it cites the standing one, empty grounds (or a hold with no exit condition) is refused, and a state the item's position does not make available is refused with the availability rule named. +- **Given** a widening item with no admission and no disposition, and a + committed comparative run over its run, **when** the user admits it with a + ground above the floor, **then** an `accepted` disposition and one admission + record exist naming the item and the run; a second admission refuses, a + standing `declined` or `held` refuses naming the disposition, and before any + comparative run names the run both the admission and a disposition refuse + naming what they wait for. +- **Given** a surprise whose occasion resolves to a reading item, an admission + or a disposition, **when** the user records it, **then** one surprise record + exists as its own file and no disposition was touched. +- **Given** a run of widening items, **when** the bare board or `abcd lint` + runs, **then** it counts the run's admitted, declined and held proposals and + names each one carrying neither an admission nor a `declined` or `held` + disposition. - **Given** a reading item carrying no disposition, **when** the user tries to promote it, **then** the promote is refused and no draft is minted: acceptance is one record, and the action it licenses is a separate admission. The same @@ -397,15 +444,15 @@ the only caller that writes them (see [`23-reading.md`](23-reading.md)). That sequencing is spc-58's own, and it is why the ingest primitive is exported rather than made a verb of this surface. -Admission and surprise records (itd-189, spc-67) ship as **schemas only**, -declared beside the reading families and wired to `record_schema` rather than to -a verb. A declined proposal is no third record type: it is the disposition in -its `declined` state. This surface has no sub-verb that writes either shape, so -what is armed today is the committed-tree gate: a blank grounds, an absent -proposal, an occasioned-by pointer naming no record, and either family filed in -the other's store are each a blocker. The command-side write is a later -iteration, and the sequencing is the reading families' own: no reading has run, -so there is nothing to write yet. +Admission and surprise records (itd-189, spc-67) have their schemas beside the +reading families, wired to `record_schema`, and their writers in +`internal/core/capture/admit.go` and `surprise.go` (spc-2609020626040342). A +declined proposal is no third record type: it is the disposition in its +`declined` state. The committed-tree gate stays armed for a record written by +hand: a blank grounds, an absent proposal, an occasion outside the closed form +or naming no record, and either family filed in the other's store are each a +blocker. `abcd ` and `abcd ` describe the record and its joins; +the reading families `rdi`, `dsp` and `rdg` have no record dispatch. @@ -415,7 +462,7 @@ _Generated from the command tree; a drift test fails `go test` when this appendi ### `abcd capture` -Sub-verbs: `abcd capture defer`, `abcd capture disposition`, `abcd capture link`, `abcd capture list`, `abcd capture mentions`, `abcd capture migrate`, `abcd capture promote`, `abcd capture resolve`, `abcd capture wontfix`. +Sub-verbs: `abcd capture admit`, `abcd capture defer`, `abcd capture disposition`, `abcd capture link`, `abcd capture list`, `abcd capture mentions`, `abcd capture migrate`, `abcd capture promote`, `abcd capture resolve`, `abcd capture surprise`, `abcd capture wontfix`. | Flag | Type | |---|---| @@ -429,6 +476,14 @@ Sub-verbs: `abcd capture defer`, `abcd capture disposition`, `abcd capture link` | `--slug` | string | | `--source` | string | +### `abcd capture admit` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--grounds` | string | + ### `abcd capture defer` Sub-verbs: none. @@ -512,6 +567,14 @@ Sub-verbs: none. | `--shipped-in` | string | | `--spec` | string | +### `abcd capture surprise` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--occasioned-by` | string | + ### `abcd capture wontfix` Sub-verbs: none. diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index 2cefb0585..4439c6c08 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -41,8 +41,14 @@ and which of the `.abcd/` work tiers exist. The plugin command invokes its JSON form. **`abcd `** takes a single positional matching `iss-N`, `itd-N`, -`spc-N` or `adr-N` and reports, read-only, what that record is, where it lives, -and the concrete next move for its lifecycle state. Bare answers *what can I +`spc-N`, `adr-N`, `adm-N`, `srp-N` or `rfm-N` and reports, read-only, what that +record is, where it lives, and the concrete next move for its lifecycle state. +An admission and a surprise are the issue ledger's two folderless families +(spc-2609020626040342): their status reads `admitted` and `recorded`, their links +are the records they join to, and neither has a next move. `rfm-N` is admitted by +the gate for the reframe record, whose description lands with it +(spc-2609020626048705); until then it is refused naming that. The reading +families have no record dispatch. Bare answers *what can I do*; the id form answers *what is this, and what is my next move* (spc-26, itd-121). A positional on the namespace root is not a `show` sub-verb, so the form stays inside the naming discipline. For an issue id it also names the diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 9d8a67aef..a6e7fcc6b 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -303,6 +303,19 @@ } ] }, + { + "path": "abcd capture admit", + "hidden": false, + "flags": [ + { + "name": "grounds", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, { "path": "abcd capture defer", "hidden": false, @@ -540,6 +553,19 @@ } ] }, + { + "path": "abcd capture surprise", + "hidden": false, + "flags": [ + { + "name": "occasioned-by", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, { "path": "abcd capture wontfix", "hidden": false, diff --git a/commands/abcd.md b/commands/abcd.md index 3d6a1d6af..b06669ff1 100644 --- a/commands/abcd.md +++ b/commands/abcd.md @@ -1,6 +1,6 @@ --- name: abcd -description: Top-level where-am-i status board and record-id dispatch. Bare `/abcd` renders a read-only snapshot of the current directory; `/abcd ` (iss-N, itd-N, spc-N, adr-N) reports what that record is and the next move. Strictly read-only. +description: Top-level where-am-i status board and record-id dispatch. Bare `/abcd` renders a read-only snapshot of the current directory; `/abcd ` (iss-N, itd-N, spc-N, adr-N, adm-N, srp-N, rfm-N) reports what that record is and the next move. Strictly read-only. argument-hint: "[]" --- @@ -60,7 +60,7 @@ read omits the lines and says why there. ## Record-id dispatch Bare answers *what can I do*; `abcd ` answers *what is this, and what is -my next move*. A positional matching `^(iss|itd|spc|adr)-[0-9]+$` locates the +my next move*. A positional matching `^(iss|itd|spc|adr|adm|srp|rfm)-[0-9]+$` locates the record in its store — any status folder or bucket — and renders it read-only: ```bash @@ -72,7 +72,12 @@ Summarise the `id`, `family`, `status`, `title`, `path`, the `links` edges present), and each entry in `next_moves` — the concrete lifecycle move (e.g. a draft intent points at the planning interview and `intent plan`; an open issue points at `capture promote` / `resolve` / `wontfix`; decisions are -read). For an issue id the JSON also carries `ledger` — the `checkout` and +read). An admission (`adm-N`) and a surprise (`srp-N`) have no folder, so their +`status` is `admitted` or `recorded`; an admission's `links` are its `run`, +`proposal`, `proposal_path` and the standing `disposition`, a surprise's are +`occasioned_by` and `occasion_path`, and neither carries a next move. An `rfm-N` +is refused naming the reframe record that has not landed. The reading families +(`rdi-N`, `dsp-N`, `rdg-N`) are not dispatched. For an issue id the JSON also carries `ledger` — the `checkout` and `branch` whose ledger was read — because the same id can sit in another worktree's ledger in another state; name it when you report. A shape-matching id found in no store exits non-zero naming the stores diff --git a/commands/capture.md b/commands/capture.md index dfed345a5..e75a921c2 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -1,7 +1,7 @@ --- name: capture -description: Capture issues to the structured per-repo ledger and query them, by invoking the abcd binary. Bare invocation is a read-only status render; defer/disposition/link/list/promote/resolve/wontfix act on the ledger, and migrate rewrites retired back-links. -argument-hint: "[text] | list --open|--resolved|--wontfix|--all | link [--blocked-by ] [--unblock ] | promote --grounds \": \" [--intent ] | promote [--intent ] | resolve --impact --grounds \": \" [--intent ] [--spec ] [--commit ] | wontfix | defer --after --reason | disposition --state | migrate [--apply]" +description: Capture issues to the structured per-repo ledger and query them, by invoking the abcd binary. Bare invocation is a read-only status render; admit/defer/disposition/link/list/promote/resolve/surprise/wontfix act on the ledger, and migrate rewrites retired back-links. +argument-hint: "[text] | list --open|--resolved|--wontfix|--all | link [--blocked-by ] [--unblock ] | promote --grounds \": \" [--intent ] | promote [--intent ] | resolve --impact --grounds \": \" [--intent ] [--spec ] [--commit ] | wontfix | defer --after --reason | disposition --state | admit --grounds \"\" | surprise --occasioned-by \"\" | migrate [--apply]" --- # `/abcd:capture` — issue ledger @@ -430,19 +430,60 @@ 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. -**Admissions and surprises are written by hand.** A widening proposal admitted -into the candidate set carries an **admission record** (`adm-N`, under -`.abcd/work/issues/admissions//`) whose `grounds` say what it was -admitted on; a **surprise entry** (`srp-N`, under -`.abcd/work/issues/surprises/`) records what was unexpected, keyed by -`occasioned_by` to whatever occasioned it and never folded into a disposition. A -declined proposal is not a third record: it is the disposition above in its -`declined` state. Neither shape has a sub-verb — this surface writes no `adm-N` -and no `srp-N`, and the command-side refusal is the next iteration's. What holds -today is the committed-tree gate: `record_schema` refuses an admission whose -`grounds` carries no value on the key's own line, an admission with no -`proposal`, a surprise whose `occasioned_by` names a record the corpus does not -hold, and either record filed in the other's store. +**At the widening position, characterise first and admit second.** No +disposition in any state (`accepted`, `declined` or `held`) and no admission is +written for a widening item until a committed comparative run names the item's +run. The refusal names the run and says what it is waiting for: the comparative +reading over that run, ingested through `/abcd:reading`. A comparative run +committed with an empty item set, the position not exercised, satisfies it too. +Every other position is answered with no comparative run anywhere. Relay the +refusal; do not write the record by hand to get past it. + +## Admit a widening proposal + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" capture admit --grounds "" --json +``` + +At the widening position acceptance **is** admission, so admitting is one act +that writes two records under the ledger lock: the item's `accepted` +disposition and an **admission record** (`adm-N`, under +`.abcd/work/issues/admissions//`) joining it to its run's candidate set. +Both carry the one ground given. Where an `accepted` disposition already stands, +the admission is written alone, and `--grounds` must be that disposition's +ground. Report the `admission`, `disposition`, `disposition_written`, `run` and +`path` from the JSON, and `redacted` whenever it is non-zero. + +`--grounds` is free text with no `:` prefix, held to the same substance +floor as every other grounds argument. Everything the verb refuses writes +nothing: an item at another position (answer it with `disposition` instead), an +item already admitted, a standing disposition in any other state (the refusal +names it and its state), more than one standing answer, a blank or degenerate +ground, a ground that differs from a standing acceptance's, and any admission +before the comparative run. Declining is not this verb: it is `disposition + --state declined --grounds ""`. + +## Record a surprise + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" capture surprise --occasioned-by "" --json +``` + +A **surprise entry** (`srp-N`, under `.abcd/work/issues/surprises/`) records +what was unexpected as its own record, never as a field on a disposition: the +text is its body and `occasioned_by` is its whole join. The occasion is a +reading item, an admission or a disposition this ledger holds, and nothing else. +Prose, a record of any other family and a handle naming nothing are refused, as +is a missing `--occasioned-by` and a text below the grounds floor; nothing is +written. Report the `id`, `occasioned_by` and `path`, and `redacted` whenever it +is non-zero. `/abcd ` and `/abcd ` describe either record and what +it joins to. + +The committed-tree gate holds a record written by hand to the same shapes: +`record_schema` refuses an admission whose `grounds` carries no value on the +key's own line, an admission with no `proposal`, a surprise whose +`occasioned_by` is not an `rdi-N`, `adm-N` or `dsp-N` naming a record the +corpus holds, and either record filed in the other's store. Carrying no value is judged by the kind of YAML node the value is, not by the literal it is spelled with, so there is no list to fall outside of: empty, whitespace, quoted-empty, quoted-whitespace, an empty flow collection (`[]`, @@ -451,8 +492,12 @@ whitespace, quoted-empty, quoted-whitespace, an empty flow collection (`[]`, alias (`!!str ''`, `!!seq []`, `&anchor`, `*alias`), and a block scalar holding nothing all carry nothing alike. A trailing comment is stripped before the value is judged, so it hides none of them. -`abcd lint` reports a widening proposal carrying neither an admission nor a -decline, at `info`. + +The bare board and `abcd lint` count each widening run: its proposals, how many +were admitted, declined and held, and which carry neither an admission nor a +`declined` or `held` disposition (`widening_runs` in the board's JSON). A +widening proposal carrying neither an admission nor a decline is also reported +on its own line, at `info`. ## Promote an issue into an intent diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index a617cd67b..78702ed77 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -173,6 +173,18 @@ Capture issues to the ledger; bare invocation is read-only status --source string surfacing channel: plan-review | impl-review | manual-test | review-followup | agent-finding | agent-observation | user-observation | drift-detection | memory-curation | managed-repo (default user-observation) ``` +#### `abcd capture admit` + +Admit one widening proposal: its accepted disposition and its admission record, as one act + +**Usage:** `abcd capture admit --grounds "" [flags]` + +**Flags:** + +``` + --grounds string why the proposal is admitted (free text, held to the grounds floor; on a standing acceptance it must be that acceptance's ground) +``` + #### `abcd capture defer` Carry an open major or critical record past the current release cut (writes deferred_after + deferral_reason; stays in open/) @@ -288,6 +300,18 @@ Mark an open issue resolved (open/ -> resolved/), optionally naming what fixed i --spec string resolved_by provenance: the spc-N that fixed it (must exist) ``` +#### `abcd capture surprise` + +Record one surprise as its own record, keyed to the item, admission or disposition that occasioned it + +**Usage:** `abcd capture surprise --occasioned-by "" [flags]` + +**Flags:** + +``` + --occasioned-by string the record that occasioned it: a reading item (rdi-N), an admission (adm-N) or a disposition (dsp-N) +``` + #### `abcd capture wontfix` Record an explicit non-action decision (open/ -> wontfix/) diff --git a/internal/surface/cli/capture_admit_test.go b/internal/surface/cli/capture_admit_test.go new file mode 100644 index 000000000..ec13b2a6c --- /dev/null +++ b/internal/surface/cli/capture_admit_test.go @@ -0,0 +1,147 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +const ( + admitRun = "rdg-2608300000000001" + admitItem = "rdi-2608300000000002" +) + +// writeWideningFixture lays down one widening item and, when characterised, the +// committed comparative run over its run — the marker the ordering gate reads. +func writeWideningFixture(t *testing.T, repo string, characterised bool) { + t.Helper() + dir := filepath.Join(repo, ".abcd", "work", "issues", "readings", admitRun) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, admitItem+".md"), []byte("---\n"+ + "schema_version: 1\nid: \""+admitItem+"\"\nrun: \""+admitRun+"\"\nmanifest: \"sha256:beef\"\n"+ + "position: \"widening\"\nregime: \"generative\"\npattern: \"a stated constraint\"\n"+ + "configuration: \"a third arrangement the frame does not hold\"\n"+ + "what_admits_it: \"the constraint the record already states\"\n---\n\n"), 0o644); err != nil { + t.Fatal(err) + } + if !characterised { + return + } + comp := filepath.Join(repo, ".abcd", "development", "readings", "rdg-2608300000000009") + if err := os.MkdirAll(comp, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(comp, "run.json"), + []byte(`{"run_id":"rdg-2608300000000009","position":"comparative","candidate_run":"`+admitRun+`"}`), 0o644); err != nil { + t.Fatal(err) + } +} + +// `capture admit` is a front door: reachable from the CLI, rendering both ids, +// reporting `redacted` when the ground carried something the ledger never +// commits, and refusing before characterisation with nothing written. +func TestCaptureAdmitRendersAndRedacts(t *testing.T) { + repo := captureLedgerRepo(t) + writeWideningFixture(t, repo, false) + out, err := runCLIErr(t, "capture", "admit", admitItem, "--grounds", "the configuration engages the frame the reading characterised") + if err == nil || !strings.Contains(err.Error(), "comparative") { + t.Fatalf("an admit before the comparative run must refuse naming what it waits for; err = %v\n%s", err, out) + } + if _, serr := os.Stat(filepath.Join(repo, ".abcd", "work", "issues", "admissions")); !os.IsNotExist(serr) { + t.Fatalf("a refused admit wrote into the admissions store: %v", serr) + } + + writeWideningFixture(t, repo, true) + home := t.TempDir() + t.Setenv("HOME", home) + out = runCLI(t, "capture", "admit", admitItem, "--grounds", + "the configuration engages the frame noted in "+filepath.Join(home, "notes", "frame.md"), "--json") + var r struct { + Admission string `json:"admission"` + Disposition string `json:"disposition"` + DispositionWritten bool `json:"disposition_written"` + Path string `json:"path"` + Run string `json:"run"` + Redacted int `json:"redacted"` + } + if err := json.Unmarshal(out, &r); err != nil { + t.Fatalf("admit output not JSON: %v\n%s", err, out) + } + if !strings.HasPrefix(r.Admission, "adm-") || !strings.HasPrefix(r.Disposition, "dsp-") || + !r.DispositionWritten || r.Run != admitRun || r.Redacted == 0 { + t.Fatalf("admit result = %+v", r) + } + raw, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(r.Path))) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), home) { + t.Fatalf("the admission carries the caller's home root:\n%s", raw) + } + + // The board carries the run's count. + board := runCLI(t, "capture", "--json") + var b struct { + Outstanding struct { + WideningRuns []struct { + Run string `json:"run"` + Items int `json:"items"` + Admitted int `json:"admitted"` + Outstanding []string `json:"outstanding"` + } `json:"widening_runs"` + } `json:"reading_outstanding"` + } + if err := json.Unmarshal(board, &b); err != nil { + t.Fatalf("capture board not JSON: %v\n%s", err, board) + } + if len(b.Outstanding.WideningRuns) != 1 || b.Outstanding.WideningRuns[0].Admitted != 1 || + b.Outstanding.WideningRuns[0].Outstanding == nil { + t.Fatalf("widening_runs = %+v\n%s", b.Outstanding.WideningRuns, board) + } + text := string(runCLI(t, "capture")) + if !strings.Contains(text, "widening "+admitRun+" — 1 proposal(s): 1 admitted, 0 declined, 0 held, 0 outstanding") { + t.Fatalf("the board must render the run's count:\n%s", text) + } +} + +// `capture surprise` needs its occasion: without --occasioned-by it refuses and +// writes nothing; with a resolving occasion it writes one record. +func TestCaptureSurpriseRequiresAnOccasion(t *testing.T) { + repo := captureLedgerRepo(t) + writeWideningFixture(t, repo, false) + surprises := filepath.Join(repo, ".abcd", "work", "issues", "surprises") + + out, err := runCLIErr(t, "capture", "surprise", "the reading ranked the unexpected proposal first") + if err == nil || !strings.Contains(err.Error(), "--occasioned-by") { + t.Fatalf("a surprise with no occasion must refuse naming the flag; err = %v\n%s", err, out) + } + if _, serr := os.Stat(surprises); !os.IsNotExist(serr) { + t.Fatalf("a refused surprise wrote into the surprise store: %v", serr) + } + + out = runCLI(t, "capture", "surprise", "--occasioned-by", admitItem, + "the reading ranked the unexpected proposal first", "--json") + var r struct { + ID string `json:"id"` + OccasionedBy string `json:"occasioned_by"` + Path string `json:"path"` + } + if err := json.Unmarshal(out, &r); err != nil { + t.Fatalf("surprise output not JSON: %v\n%s", err, out) + } + if !strings.HasPrefix(r.ID, "srp-") || r.OccasionedBy != admitItem { + t.Fatalf("surprise result = %+v", r) + } + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(r.Path))); err != nil { + t.Fatalf("the surprise record must exist: %v", err) + } + // And `abcd srp-N` dispatches to it. + d := string(runCLI(t, r.ID)) + if !strings.Contains(d, "surprise, recorded") || !strings.Contains(d, "occasioned_by: "+admitItem) { + t.Fatalf("abcd %s:\n%s", r.ID, d) + } +} diff --git a/internal/surface/cli/capture_root_test.go b/internal/surface/cli/capture_root_test.go index 693e3865a..b991db174 100644 --- a/internal/surface/cli/capture_root_test.go +++ b/internal/surface/cli/capture_root_test.go @@ -293,6 +293,37 @@ func TestEveryCaptureVerbAddressesTheCheckoutLedger(t *testing.T) { } }, }, + // The fixture's item is a detection, so the checkout's ledger answers with + // the position refusal; a subdirectory ledger would not know the item. + "admit": { + args: func(_ []string, item string) []string { + return []string{"capture", "admit", item, "--grounds", "the configuration engages the frame it widens", "--json"} + }, + check: func(t *testing.T, _ string, _ []string, item string, out []byte, err error) { + if err == nil || !strings.Contains(string(out)+err.Error(), "is a detection item") { + t.Fatalf("capture admit %s from the subdirectory did not read the checkout's item: %v\n%s", item, err, out) + } + }, + }, + "surprise": { + args: func(_ []string, item string) []string { + return []string{"capture", "surprise", "--occasioned-by", item, "the tension ran the other way from the one expected", "--json"} + }, + check: func(t *testing.T, repo string, _ []string, item string, out []byte, err error) { + if err != nil { + t.Fatalf("capture surprise from the subdirectory: %v\n%s", err, out) + } + var res struct { + Path string `json:"path"` + } + if jerr := json.Unmarshal(out, &res); jerr != nil { + t.Fatalf("capture surprise --json: not JSON: %v\n%s", jerr, out) + } + if _, serr := os.Stat(filepath.Join(repo, filepath.FromSlash(res.Path))); serr != nil { + t.Errorf("the surprise reported at %q is not in the checkout ledger: %v", res.Path, serr) + } + }, + }, "disposition": { args: func(_ []string, item string) []string { return []string{"capture", "disposition", item, "--state", "accepted", diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 71a0803a6..af0192615 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3497,8 +3497,8 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { // grounds or by a decline, so it gets its own line: the // disposition-only line above would name the wrong remedy. for _, o := range board.Outstanding.Unadmitted { - fmt.Fprintf(w, " unadmitted %s (run %s) — a widening proposal with neither an admission nor a decline\n", - termsafe.Sanitize(o.Item), termsafe.Sanitize(o.Run)) + fmt.Fprintf(w, " unadmitted %s (run %s) — a widening proposal with neither an admission nor a decline; `abcd capture admit %s --grounds \"\"` writes the admission\n", + termsafe.Sanitize(o.Item), termsafe.Sanitize(o.Run), termsafe.Sanitize(o.Item)) } // More than one standing answer is named in full, never resolved // by picking one: which is in force is a judgement the ledger @@ -3526,6 +3526,16 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { fmt.Fprintf(w, " unread %s — %s; what it holds is neither outstanding nor answered\n", termsafe.Sanitize(u.Path), termsafe.Sanitize(u.Reason)) } + // The per-run count makes the admitted-against-declined + // balance a query rather than an inspection. + for _, r := range board.Outstanding.WideningRuns { + line := fmt.Sprintf(" widening %s — %d proposal(s): %d admitted, %d declined, %d held, %d outstanding", + termsafe.Sanitize(r.Run), r.Items, r.Admitted, r.Declined, r.Held, len(r.Outstanding)) + if len(r.Outstanding) > 0 { + line += " (" + termsafe.Sanitize(strings.Join(r.Outstanding, ", ")) + ")" + } + fmt.Fprintln(w, line) + } for _, h := range board.Outstanding.OpenHolds { fmt.Fprintf(w, " held %s (%s) — exits when: %s\n", termsafe.Sanitize(h.Item), termsafe.Sanitize(h.Disposition), @@ -3993,6 +4003,81 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { dispositionCmd.Flags().StringVar(&dispHoldMoscow, "hold-moscow", "", "RESERVED (dormant): must | should | could | wont; a populated value is refused until activation is ruled") captureCmd.AddCommand(dispositionCmd) + // admit — one admission as one act (spc-2609020626040342): the widening + // item's `accepted` disposition and the admission record joining it to its + // run's candidate set, under one lock, carrying one ground. The ruled order + // (characterise first, admit second) is the core's refusal, not this door's. + var admitGrounds string + admitCmd := &cobra.Command{ + Use: "admit --grounds \"\"", + Short: "Admit one widening proposal: its accepted disposition and its admission record, as one act", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + repoRoot, err := captureLedgerRoot(cmd) + if err != nil { + return err + } + res, err := capture.Admit(capture.AdmitRequest{RepoRoot: repoRoot, Item: args[0], Grounds: admitGrounds}) + if err != nil { + return err + } + return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { + fmt.Fprintf(w, "%s %s admitted into %s — %s\n", + res.Admission, res.Item, res.Run, termsafe.Sanitize(res.Path)) + if res.DispositionWritten { + fmt.Fprintf(w, " %s accepted — %s\n", res.Disposition, termsafe.Sanitize(res.DispositionPath)) + } else { + fmt.Fprintf(w, " %s accepted (already standing; the admission is written alone)\n", res.Disposition) + } + if res.Redacted > 0 { + fmt.Fprintf(w, " redacted %d span(s) before writing (home paths and identifiers are never committed)\n", res.Redacted) + } + if res.Degraded != "" { + fmt.Fprintf(w, " WARNING: %s\n", termsafe.Sanitize(res.Degraded)) + } + }) + }, + } + // --grounds carries no default and is not cobra-required, on the disposition + // verb's shape: the core refuses an empty or degenerate ground and writes + // nothing. + admitCmd.Flags().StringVar(&admitGrounds, "grounds", "", "why the proposal is admitted (free text, held to the grounds floor; on a standing acceptance it must be that acceptance's ground)") + captureCmd.AddCommand(admitCmd) + + // surprise — one surprise entry, its own record keyed to the reading item, + // admission or disposition that occasioned it (spc-2609020626040342). Never + // a field on a disposition. + var surpriseOccasion string + surpriseCmd := &cobra.Command{ + Use: "surprise --occasioned-by \"\"", + Short: "Record one surprise as its own record, keyed to the item, admission or disposition that occasioned it", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(surpriseOccasion) == "" { + return &exitError{Code: 2, Msg: "abcd capture surprise: --occasioned-by is required — a surprise is keyed to the record that occasioned it (nothing written)"} + } + repoRoot, err := captureLedgerRoot(cmd) + if err != nil { + return err + } + res, err := capture.Surprise(capture.SurpriseRequest{RepoRoot: repoRoot, OccasionedBy: surpriseOccasion, Text: args[0]}) + if err != nil { + return err + } + return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { + fmt.Fprintf(w, "%s occasioned by %s — %s\n", res.ID, res.OccasionedBy, termsafe.Sanitize(res.Path)) + if res.Redacted > 0 { + fmt.Fprintf(w, " redacted %d span(s) before writing (home paths and identifiers are never committed)\n", res.Redacted) + } + if res.Degraded != "" { + fmt.Fprintf(w, " WARNING: %s\n", termsafe.Sanitize(res.Degraded)) + } + }) + }, + } + surpriseCmd.Flags().StringVar(&surpriseOccasion, "occasioned-by", "", "the record that occasioned it: a reading item (rdi-N), an admission (adm-N) or a disposition (dsp-N)") + captureCmd.AddCommand(surpriseCmd) + // wontfix — open -> wontfix with a reason. It needs no required --grounds: // the reason is already mandatory, so a wontfix could never be recorded // without grounds — what it lacked was the TYPE, which it stamps as @@ -4394,6 +4479,9 @@ func captureBoardOf(repoRoot string, st capture.StatusResult) (captureBoard, err if report.Unsafe == nil { report.Unsafe = []lint.UnsafePath{} } + if report.WideningRuns == nil { + report.WideningRuns = []lint.WideningRun{} + } if report.Cyclic == nil { report.Cyclic = []lint.OutstandingItem{} } From 7bcfcdd00dcda93568dbb875a9a9833c3f112a29 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:11:18 +0100 Subject: [PATCH 06/78] chore: capture the stale reading-item producer paragraph Refs: iss-2609251711096816 Assisted-by: Claude:claude-opus-5-5 --- ...ure-md-s-where-an-rdi-n-comes-from-paragraph.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609251711096816-commands-capture-md-s-where-an-rdi-n-comes-from-paragraph.md diff --git a/.abcd/work/issues/open/iss-2609251711096816-commands-capture-md-s-where-an-rdi-n-comes-from-paragraph.md b/.abcd/work/issues/open/iss-2609251711096816-commands-capture-md-s-where-an-rdi-n-comes-from-paragraph.md new file mode 100644 index 000000000..086547ff4 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251711096816-commands-capture-md-s-where-an-rdi-n-comes-from-paragraph.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251711096816" +slug: "commands-capture-md-s-where-an-rdi-n-comes-from-paragraph" +severity: "minor" +category: "documentation" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "commands/capture.md" +--- + +commands/capture.md's 'Where an rdi-N comes from' paragraph says the cold-reading ingest verb has not landed and that the reading-item sub-verbs have nothing to act on until it does; the ingest verb ships (abcd reading ingest, commands/reading.md), and four capture sub-verbs now act on reading items (disposition, admit, surprise, promote), so the paragraph states a future that is already the past and counts two verbs. From 94b775c9f28615aae5e24589ca8e29e0f69313be Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:11:20 +0100 Subject: [PATCH 07/78] docs(capture): the reading-item producer ships; four verbs act on its items The plugin page said the cold-reading ingest verb had not landed and that two sub-verbs had nothing to act on until it did. The ingest verb ships as `reading ingest`, and disposition, admit, surprise and promote all act on reading items, so the paragraph names the verb and the four. Refs: iss-2609251711096816 Assisted-by: Claude:claude-opus-5-5 --- commands/capture.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/commands/capture.md b/commands/capture.md index e75a921c2..b7c90cbc4 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -397,11 +397,12 @@ The two are never one write, so the ledger can always show that a finding existed before it was answered. Report the `id`, `item`, `state`, `position` and `path` from the JSON. -**Where an `rdi-N` comes from.** This surface answers and promotes reading items; -it does not produce them. The one writer of the `rdi-N` family is the -cold-reading ingest verb, which owns the output contract a reading is validated -against — until that verb lands there is no reading item to answer, and these two -sub-verbs have nothing to act on. +**Where an `rdi-N` comes from.** This surface answers, admits, promotes and +records surprises about reading items; it does not produce them. The one writer +of the `rdi-N` family is the cold-reading ingest verb (`/abcd:reading`, `reading +ingest`), which owns the output contract a reading is validated against. Until a +reading has been ingested there is no reading item to answer, and `disposition`, +`admit`, `surprise` and `promote ` have nothing to act on. Four states ship: `accepted` (at the widening position, acceptance IS admission), `rejected` (asserts a purpose a later run tests), `declined` (the From b0a7683a69f5b2cf119165ba8b4f00263d2f6293 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:11:27 +0100 Subject: [PATCH 08/78] =?UTF-8?q?chore:=20resolve=20iss-2609251711096816?= =?UTF-8?q?=20=E2=80=94=20reading-item=20producer=20paragraph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251711096816 Assisted-by: Claude:claude-opus-5-5 --- ...ds-capture-md-s-where-an-rdi-n-comes-from-paragraph.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251711096816-commands-capture-md-s-where-an-rdi-n-comes-from-paragraph.md (67%) diff --git a/.abcd/work/issues/open/iss-2609251711096816-commands-capture-md-s-where-an-rdi-n-comes-from-paragraph.md b/.abcd/work/issues/resolved/iss-2609251711096816-commands-capture-md-s-where-an-rdi-n-comes-from-paragraph.md similarity index 67% rename from .abcd/work/issues/open/iss-2609251711096816-commands-capture-md-s-where-an-rdi-n-comes-from-paragraph.md rename to .abcd/work/issues/resolved/iss-2609251711096816-commands-capture-md-s-where-an-rdi-n-comes-from-paragraph.md index 086547ff4..6d048a5e6 100644 --- a/.abcd/work/issues/open/iss-2609251711096816-commands-capture-md-s-where-an-rdi-n-comes-from-paragraph.md +++ b/.abcd/work/issues/resolved/iss-2609251711096816-commands-capture-md-s-where-an-rdi-n-comes-from-paragraph.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "commands/capture.md" +resolution: "the plugin page names the shipped ingest verb and the four sub-verbs that act on reading items" +impact: internal +resolved_by: + commit: "94b775c9f28615aae5e24589ca8e29e0f69313be" --- commands/capture.md's 'Where an rdi-N comes from' paragraph says the cold-reading ingest verb has not landed and that the reading-item sub-verbs have nothing to act on until it does; the ingest verb ships (abcd reading ingest, commands/reading.md), and four capture sub-verbs now act on reading items (disposition, admit, surprise, promote), so the paragraph states a future that is already the past and counts two verbs. + +## Grounds + +- pursued: the page now describes the surface as it ships; a reader following it to a verb that does not exist, or missing admit and surprise, would show it wrong From 30f22aaae209db8c0afdfd28670a22285e528404 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:11:38 +0100 Subject: [PATCH 09/78] chore: ship itd-2609020625400194 by closing spc-2609020626040342 The admission and surprise verbs, the shared disposition writer's ordering gate, the closed surprise occasion, the adm/srp dispatch and the per-run widening count are delivered. The fidelity review is owed (receipt rcp-77c7c87559c2). Delivers: itd-2609020625400194 Assisted-by: Claude:claude-opus-5-5 --- ...on-and-a-surprise-are-written-by-a-verb-and-the-or.md | 9 +++++---- ...ive-reading-receives-the-widening-run-s-items-as-i.md | 2 +- ...on-and-a-surprise-are-written-by-a-verb-and-the-or.md | 6 +++--- ...-s-context-is-assembled-and-its-output-is-ingested.md | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) rename .abcd/development/intents/{planned => shipped}/itd-2609020625400194-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md (83%) rename .abcd/development/specs/{open => closed}/spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md (98%) diff --git a/.abcd/development/intents/planned/itd-2609020625400194-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md b/.abcd/development/intents/shipped/itd-2609020625400194-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md similarity index 83% rename from .abcd/development/intents/planned/itd-2609020625400194-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md rename to .abcd/development/intents/shipped/itd-2609020625400194-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md index cde603ace..f2cf1a701 100644 --- a/.abcd/development/intents/planned/itd-2609020625400194-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md +++ b/.abcd/development/intents/shipped/itd-2609020625400194-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md @@ -14,7 +14,7 @@ production_mode: dictated-and-formatted # An admission and a surprise are written by a verb, and the order the design fixes is a refusal -Typed links: `builds_on` [itd-189](../shipped/itd-189-what-the-widening-reading-proposes-is-admitted-or-declined-o.md) (the admission and surprise schemas), [itd-180](../shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) (dispositions), [itd-185](../shipped/itd-185-one-ingest-verb-validates-every-cold-reading-output-includin.md) (the ingest verb); `refines` [itd-180](../shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) (admission is the `accepted` disposition plus the admission record, written as one act, flagged for the maintainer). +Typed links: `builds_on` [itd-189](itd-189-what-the-widening-reading-proposes-is-admitted-or-declined-o.md) (the admission and surprise schemas), [itd-180](itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) (dispositions), [itd-185](itd-185-one-ingest-verb-validates-every-cold-reading-output-includin.md) (the ingest verb); `refines` [itd-180](itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) (admission is the `accepted` disposition plus the admission record, written as one act, flagged for the maintainer). ## Press Release @@ -24,7 +24,7 @@ Typed links: `builds_on` [itd-189](../shipped/itd-189-what-the-widening-reading- ## Why This Matters -[itd-189](../shipped/itd-189-what-the-widening-reading-proposes-is-admitted-or-declined-o.md) places the recording burden on admission: rejecting a proposal costs nothing epistemically, and admitting one into the candidate set is where the frame is engaged. Its scope text says the enforcement is hand-run in Iteration 1, no reading running to produce proposals, and enforced at the command in Iteration 2. Iteration 1 delivered the schemas: the admission family, the surprise family, the store layout and the gate that refuses a blank ground on a committed record. It delivered no verb, and its fidelity verdict named filing the enforcement intent as "the concrete next step this verdict asks for". +[itd-189](itd-189-what-the-widening-reading-proposes-is-admitted-or-declined-o.md) places the recording burden on admission: rejecting a proposal costs nothing epistemically, and admitting one into the candidate set is where the frame is engaged. Its scope text says the enforcement is hand-run in Iteration 1, no reading running to produce proposals, and enforced at the command in Iteration 2. Iteration 1 delivered the schemas: the admission family, the surprise family, the store layout and the gate that refuses a blank ground on a committed record. It delivered no verb, and its fidelity verdict named filing the enforcement intent as "the concrete next step this verdict asks for". The gate that reads committed records has two open defects that a verb closes for the records it writes: its blank refusal decides on literal spellings rather than on the YAML null and empty class ([iss-2608301808198621](../../../work/issues/resolved/iss-2608301808198621-isabsentvalue-decides-on-literal-strings-rather-than-the-yam.md)), and a trailing comment on a key defeats every spelling it refuses ([iss-2608301744268001](../../../work/issues/resolved/iss-2608301744268001-a-trailing-comment-on-a-frontmatter-key-defeats-every-blank.md)). Both belong where the scanner lives and are fixed there, in their own change; a verb that cannot write a blank is the other half. @@ -74,7 +74,7 @@ We expect a verb that refuses a blank ground to make the admission asymmetry leg ## Prior Art -- [itd-189](../shipped/itd-189-what-the-widening-reading-proposes-is-admitted-or-declined-o.md) and its spec (the schemas), [itd-180](../shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) (dispositions), adr-56 (absence as a class, ruled for the exclusion floor). +- [itd-189](itd-189-what-the-widening-reading-proposes-is-admitted-or-declined-o.md) and its spec (the schemas), [itd-180](itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) (dispositions), adr-56 (absence as a class, ruled for the exclusion floor). - The cold-reading rulings of 2026-08-28 in the decision log. ## Open Questions @@ -83,7 +83,8 @@ None beyond the flagged decision above. ## Audit Notes -_Empty. Populated by intent-auditor when intent moves to shipped/._ + +Fidelity review OWED (receipt rcp-77c7c87559c2). ## Grounds diff --git a/.abcd/development/specs/closed/spc-2609020626039834-a-comparative-reading-receives-the-widening-run-s-items-as-i.md b/.abcd/development/specs/closed/spc-2609020626039834-a-comparative-reading-receives-the-widening-run-s-items-as-i.md index c38764978..fb30565a8 100644 --- a/.abcd/development/specs/closed/spc-2609020626039834-a-comparative-reading-receives-the-widening-run-s-items-as-i.md +++ b/.abcd/development/specs/closed/spc-2609020626039834-a-comparative-reading-receives-the-widening-run-s-items-as-i.md @@ -71,7 +71,7 @@ the exported durable-tier writer; the definition's Object section; the eval rows, plants and counts; the plugin page and the brief chapter. Out: Admission itself and the verb that writes it, which are -[spc-2609020626040342](../open/spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md)'s, +[spc-2609020626040342](spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md)'s, including the gate in the shared disposition writer that holds the ruled ordering; any characterisation performed by the assembler; a candidate set drawn from more than one run; the preset file's own version, which diff --git a/.abcd/development/specs/open/spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md b/.abcd/development/specs/closed/spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md similarity index 98% rename from .abcd/development/specs/open/spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md rename to .abcd/development/specs/closed/spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md index ab05ff145..66b96e071 100644 --- a/.abcd/development/specs/open/spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md +++ b/.abcd/development/specs/closed/spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md @@ -10,7 +10,7 @@ production_mode: dictated-and-formatted ## Summary spc-2609020626040342 delivers -[itd-2609020625400194](../../intents/planned/itd-2609020625400194-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md). +[itd-2609020625400194](../../intents/shipped/itd-2609020625400194-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md). `abcd capture admit --grounds ""` records an admission as one act under the ledger lock: The item's `accepted` disposition carrying the grounds, and the admission record joining it to its run's candidate set. Where @@ -33,7 +33,7 @@ The flagged decision is built under its flagged reading: Admission is the refines [itd-180](../../intents/shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) and closes the state -[spc-67](../closed/spc-67-what-the-widening-reading-proposes-is-admitted-or-declined-o.md) +[spc-67](spc-67-what-the-widening-reading-proposes-is-admitted-or-declined-o.md) left, where an `accepted` item was still reported unadmitted. ## Scope @@ -52,7 +52,7 @@ Out: The scanner fix for absence as a class [iss-2608301744268001](../../../work/issues/resolved/iss-2608301744268001-a-trailing-comment-on-a-frontmatter-key-defeats-every-blank.md)); the disposition vocabulary; the comparative channel and the committed comparative run it produces, which -[spc-2609020626039834](../closed/spc-2609020626039834-a-comparative-reading-receives-the-widening-run-s-items-as-i.md) +[spc-2609020626039834](spc-2609020626039834-a-comparative-reading-receives-the-widening-run-s-items-as-i.md) defines and this gate only reads; the reading-item locator leaf, which spc-2609020626046252 introduces and this spec calls; enforcing that a session ended. diff --git a/.abcd/development/specs/open/spc-2609020626045177-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md b/.abcd/development/specs/open/spc-2609020626045177-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md index 5765ea404..c5b99ba37 100644 --- a/.abcd/development/specs/open/spc-2609020626045177-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md +++ b/.abcd/development/specs/open/spc-2609020626045177-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md @@ -213,7 +213,7 @@ because silence is not one of the scribe's options. Writes go through the verbs' own functions, in payload order: `capture.Disposition`, then `capture.Admit`, then `capture.Surprise` (the last two delivered by -[spc-2609020626040342](spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md)), +[spc-2609020626040342](../closed/spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md)), each under the ledger lock it takes for itself, each inheriting the redaction and the refusals it already applies. The verb adds no validation path of its own. Two inherited refusals are named here because a scribe payload meets From 0d86729cf7b1b416e13bb4c3d9d4558c3b7c6203 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:15:08 +0100 Subject: [PATCH 10/78] test(evals): rehearse the widening ordering gate before the decline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opening-run rehearsal declined a widening item with no comparative run naming its run, which the shared disposition writer now refuses. The step rehearses the refusal first — it names the run and the comparative run it waits for — then places the committed run record the channel writes and lands the decline, as the ruled order has it. Part of spc-2609020626040342. Assisted-by: Claude:claude-opus-5-5 --- evals/coldreading_rehearsal_test.go | 38 +++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/evals/coldreading_rehearsal_test.go b/evals/coldreading_rehearsal_test.go index 40ab399fe..693c7af95 100644 --- a/evals/coldreading_rehearsal_test.go +++ b/evals/coldreading_rehearsal_test.go @@ -294,7 +294,7 @@ func TestRehearseTheOpeningRunLoop(t *testing.T) { if len(detectionItems) == 0 || len(wideningItems) < 2 { t.Fatal("the earlier ingests left no item to disposition, so this step would assert nothing") } - rehearseDispositions(t, f, detectionItems[0], wideningItems) + rehearseDispositions(t, f, detectionItems[0], parked[posWidening].RunID, wideningItems) }) // ------------------------------------------------------------------- @@ -1251,7 +1251,7 @@ type dispositionResult struct { // end it is a parking space. Companion 4.3 is the same rule from the other side. // Availability is per POSITION and is read off the item's own record, never // supplied. -func rehearseDispositions(t *testing.T, f fixture, detectionItem string, wideningItems []string) { +func rehearseDispositions(t *testing.T, f fixture, detectionItem, wideningRun string, wideningItems []string) { t.Helper() // The acceptance, on the detection item, with its grounds. @@ -1326,7 +1326,24 @@ func rehearseDispositions(t *testing.T, f fixture, detectionItem string, widenin // else. It is the answer that costs nothing epistemically — a proposal // weighed and not taken up — and at a position whose items are findings // rather than proposals there is nothing to decline (framework 11.2). + // + // At the widening position the order is ruled: characterise first, answer + // second (spc-2609020626040342). Before a committed comparative run names the + // widening run, the decline refuses and names what it is waiting for. This + // loop cannot assemble that comparative run in place — step 1b says why, and + // TestTheComparativeAssemblyFollowsTheCommittedWideningIngest rehearses the + // channel end to end — so the committed run record the channel writes is + // placed as the marker the gate reads, and the decline then lands. t.Run("declined-is-available-at-widening", func(t *testing.T) { + out, code := runIn(t, f.Root, []string{"HOME=" + f.Home}, "capture", "disposition", wideningItems[0], + "--state", "declined", "--grounds", "the configuration is admissible and this iteration does not take it up") + if code == 0 { + t.Fatalf("a widening item was dispositioned before any comparative run named its run:\n%s", out) + } + if !strings.Contains(out, "comparative") || !strings.Contains(out, wideningRun) { + t.Errorf("the ordering refusal does not name the run and the comparative run it waits for:\n%s", out) + } + placeComparativeRunRecord(t, f, wideningRun) res := disposition(t, f, wideningItems[0], "--state", "declined", "--grounds", "the configuration is admissible and this iteration does not take it up") if res.State != "declined" || res.Position != posWidening { @@ -1373,6 +1390,23 @@ func disposition(t *testing.T, f fixture, item string, args ...string) dispositi // An item carrying a standing answer refuses a second one that does not cite it, // which is right and makes an item a single-use subject. Assembling another run // is what the operator would do, and it is cheap on this corpus. +// placeComparativeRunRecord writes the committed run record of a comparative run +// over wideningRun into the durable run directory: the commit marker +// capture's ordering gate reads (ComparativeRunFor), carrying the +// candidate-join subset it decodes. +func placeComparativeRunRecord(t *testing.T, f fixture, wideningRun string) { + t.Helper() + const compRun = "rdg-2609259999999999" + dir := filepath.Join(f.Root, filepath.FromSlash(rehearsalRunRecords), compRun) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "run.json"), []byte(`{"run_id":"`+compRun+ + `","position":"comparative","candidate_run":"`+wideningRun+`"}`), 0o644); err != nil { + t.Fatal(err) + } +} + func mustSecondDetectionItem(t *testing.T, f fixture) string { t.Helper() run := assembleParked(t, f, posDetection) From 113059c3769615b5c73812bdcbaccdf886e3e716 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:32:37 +0100 Subject: [PATCH 11/78] docs(decisions): record why run A delivers the admission verbs beside the parked Phase 9 branch 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 f0feb20a2..f2d90feb3 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2538,3 +2538,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-25 — Two rulings for the model-tier routing table, which the spec leaves open (lane implementer, autonomous run A, on spc-2609180535002478 part 1). First, a table is accepted when a routing file exists at the repository or the machine layer. Only then does the bundled proposal fill in an agent the table has no row for; with neither file, every agent resolves to `none`, the harness at `host-decides`, as AC 1 and the spec's criteria section say. A `--route` alone accepts nothing: it overrides the one agent it names for one run. Second, no agent contract under `agents/` declares a fan-out ceiling, and every agent in the roster is a single prompt that spawns no sub-agent, so each ceiling is 1. The proposal carries it (`oracle.Ceiling`), and a row's `fan_out` above it is reported and clamped. The roster test holds the proposal to `agents/`, so an agent that gains a ceiling field moves the number there. - 2026-09-25 — The load check's stray rule is "busy for its share" (ruling H1, the product thinker via the interview session, 07:57Z, on iss-2609231947544298). A long-running process outside abcd's lanes is a stray when it uses nearly all the CPU it could get on the machine as loaded: its lifetime CPU share is measured against its fair share, the online cores divided by the runnable demand, not against a fixed 0.9 of one core, so forty busy loops each at a fortieth of the machine all count. The share test applies to the caller's own processes and to other accounts' alike, and other accounts' strays stay counted only. It is not a second sample and not a summed-cores trigger. The build reads the runnable demand as the snapshot's one-minute load average and caps the fair share at one core, so on a machine loaded no higher than its cores the rule is the near-full core it was (`machineload.FairShare`, spc-2609232027132755). This closes the band between 1.125 and 4 times the cores in which the check said nothing (pinned by `TestStrayRuleSilentBand`, succeeded by `TestStrayRuleCoversTheOversubscribedBand`), and lets the load check's remainder spec close and itd-2609231434459890 ship. The check still warns and never refuses (the 2026-09-23 entry above on that intent). - 2026-09-25 — Autonomous run A defers every open capture routed to the product thinker out loud to v0.10.0, under the product thinker's directive of 2026-09-25 ("I want the ledger drained": a capture ends fixed, wontfix with its reason, closed as a duplicate, or deferred out loud where it needs a product-thinker ruling or a planning interview). The product thinker is away, so no one in the run can give those rulings. 190 records each carry `deferred_after: "v0.10.0"` and a `deferral_reason` that quotes the ruling owed verbatim. 184 of them renew a v0.9.0 grant that lapsed when v0.10.0 re-anchored, and 6 carried none. Every question is asked once in the run's rulings-owed list, grouped under the routing pass's eleven themes: A, planning interviews already ruled "plan next cycle" (27); B, confirmations owed on rulings already given (8); C, dependency and publish sign-offs (6); D, narrowing a shipped promise (4); E, principles and conventions to adopt (23); F, record schema and lint rules (34); G, security and trust design forks (16); H, autonomous runs, implement and multi-agent planning (22); I, site, docs voice and product story (17); J, future capabilities to plan or close (27); K, parked on a trigger, or a human act outside the tree (6). Within each theme the questions covering a major record come first, and the list is the agenda for the next interview. The same pass closes 9 duplicates and 44 captures on their recorded merits, so none of those is deferred (implementer of lane records1). +- 2026-09-25 — itd-2609020625400194 (admission and surprise verbs) is delivered on main by autonomous run A's lane, built against the current main, and not by the parked `phase-9/admit` branch that delivered the same spec on 2026-09-04. Phase 9 stays parked on the product thinker's word of 2026-09-22 ("leave both parked workstreams"). Its build branch sits 213 commits behind main and ships six other intents that exist nowhere else, so the run neither merges nor edits it. The overlap is this one intent: when Phase 9 is unparked, its admission commits (the spec close in 30f42daa, with b0334b03, 5ca30f92 and 074dea98) are dropped or reconciled against this delivery. That reconciliation is owed to the product thinker and listed in the run's rulings-owed list. The ruling is reversible: nothing on the parked branches is touched (orchestrator abcd-39, autonomous run A). From 8f8c9d4ea0c0a9bf0aef35d2a966c23e0d73eaf4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:59:27 +0100 Subject: [PATCH 12/78] feat(capture): the reframe record, its fingerprints, and its exclusion from every reading A reframe occasioned by a reading is recorded as a reframe: rfm-N under .abcd/work/issues/reframes/, carrying the occasion (rdi-N, dsp-N or srp-N), the SHA-256 of each of the frame's three committed surfaces before and after, which of them changed, and the redacted ground held to the grounds floor. No text of any surface enters the record (adr-55). capture.Reframe reads the surfaces itself: the framing chapter's Construal section, the glossary terms (indexes and the scaffold excepted) and the scope chapter, at HEAD, in the working tree and along a 64-commit bounded history. Written after the commit it is one write; --open records the before half and --complete finishes it, pairing across several commits or a merge. The occasion must be committed and must strictly precede the rewrite. readingitem resolves a surprise occasion as a regular file only. issueschema declares the family and registers reframes/ in LedgerDirs, so the comparative position's derived exclusion rows carry it; the exclusion floor gains the reframe row at every position, so AssemblerVersionCore moves 1.8.0 to 1.9.0 and the charter is regenerated. The read-block eval plants LEDGER-REFRAME with its oracle row and coverage row. Decisions not in the record: the construal fingerprint hashes the body under the heading (the heading is a constant), and every surface is normalised to LF with trailing whitespace and leading blank lines trimmed, so the git reader (whose output arrives trimmed) and the working-tree reader agree. The working-tree glossary is git's tracked set plus untracked, unignored files, so an uncommitted new term is an uncommitted change. Part of spc-2609020626048705. Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/readings/README.md | 2 + evals/coldreading_coverage_test.go | 10 +- evals/coldreading_fixture_test.go | 13 + evals/coldreading_oracle_test.go | 21 +- .../.abcd/work/issues/reframes/rfm-1.md | 13 + internal/core/capture/reframe.go | 928 ++++++++++++++++++ internal/core/capture/reframe_test.go | 734 ++++++++++++++ internal/core/issueschema/admission.go | 22 +- internal/core/issueschema/ledgerdirs.go | 8 +- internal/core/issueschema/ledgerdirs_test.go | 3 +- internal/core/issueschema/reframe.go | 119 +++ internal/core/issueschema/reframe_test.go | 79 ++ internal/core/reading/include.go | 13 +- internal/core/reading/include_test.go | 2 +- internal/core/reading/reframe_test.go | 80 ++ internal/core/readingitem/readingitem.go | 34 +- internal/core/readingitem/readingitem_test.go | 39 + internal/core/recordid/valid.go | 6 + internal/core/recordid/valid_test.go | 5 + 19 files changed, 2093 insertions(+), 38 deletions(-) create mode 100644 evals/testdata/cold-reading/baseline/.abcd/work/issues/reframes/rfm-1.md create mode 100644 internal/core/capture/reframe.go create mode 100644 internal/core/capture/reframe_test.go create mode 100644 internal/core/issueschema/reframe.go create mode 100644 internal/core/issueschema/reframe_test.go create mode 100644 internal/core/reading/reframe_test.go diff --git a/.abcd/development/readings/README.md b/.abcd/development/readings/README.md index 101a2f0df..cbec86729 100644 --- a/.abcd/development/readings/README.md +++ b/.abcd/development/readings/README.md @@ -157,6 +157,7 @@ disclosed as residue. | `.abcd/work/DECISIONS.md` | file | absent from the positive walk | every position | | `.abcd/.work.local` | directory | no reading consumes the local ledger side, unconditionally and under no flag (brief invariant 14) | every position | | `the lapse log` | record type in a denied path | absent from the positive walk | every position | +| `the reframe record` | record type in a denied path | absent from the positive walk | every position | | `admission and selection grounds` | record type in a denied path | absent from the positive walk | every position | | `.abcd/development/readings` | directory | the instrument's own output is never its input | every position | | `agents` | directory | the instrument's own output is never its input | every position | @@ -173,6 +174,7 @@ disclosed as residue. | `.abcd/work/issues/dispositions` | directory | the comparative reading receives candidates and never their fate: the ledger's other families are excluded family by family, derived from the ledger's own directory list | comparative | | `.abcd/work/issues/admissions` | directory | the comparative reading receives candidates and never their fate: the ledger's other families are excluded family by family, derived from the ledger's own directory list | comparative | | `.abcd/work/issues/surprises` | directory | the comparative reading receives candidates and never their fate: the ledger's other families are excluded family by family, derived from the ledger's own directory list | comparative | +| `.abcd/work/issues/reframes` | directory | the comparative reading receives candidates and never their fate: the ledger's other families are excluded family by family, derived from the ledger's own directory list | comparative | diff --git a/evals/coldreading_coverage_test.go b/evals/coldreading_coverage_test.go index 1e699e0a3..34964f4aa 100644 --- a/evals/coldreading_coverage_test.go +++ b/evals/coldreading_coverage_test.go @@ -405,10 +405,10 @@ var coverage = []coverageRow{ }, { Rule: "the issue ledger never travels in any state, reading records, dispositions, " + - "admission and selection grounds and the lapse log included", + "admission and selection grounds, reframe records and the lapse log included", Falsifier: "add an include row under work/issues and delete the work/issues Exclusions row", Caught: caughtLeak, - Classes: []string{"DECISION", "EXHAUST", "GROUNDS"}, + Classes: []string{"DECISION", "EXHAUST", "GROUNDS", "LEDGER-REFRAME"}, }, { Rule: "the shared decision log never travels", @@ -699,6 +699,12 @@ var coverage = []coverageRow{ Caught: caughtLeak, Classes: []string{"FATE"}, }, + { + Rule: "reframe records never reach the comparative reading (spc-2609020626048705)", + Falsifier: "delete the derived reframes row and add an include row for it", + Caught: caughtLeak, + Classes: []string{"LEDGER-REFRAME"}, + }, { Rule: "the status directories never reach the comparative reading", Falsifier: "delete the three derived status rows and add an include row under .abcd/work/issues", diff --git a/evals/coldreading_fixture_test.go b/evals/coldreading_fixture_test.go index ae576a8d9..cc65e9d9a 100644 --- a/evals/coldreading_fixture_test.go +++ b/evals/coldreading_fixture_test.go @@ -299,6 +299,19 @@ var sentinelClasses = []sentinelClass{ "surprise — is warm, and a reading that saw it would be reading what it exists " + "to inform", }, + { + // The reframe record (spc-2609020626048705): a committed pointer to a + // reframe whose content stays local. It is warm at every position — the + // ledger's container row refuses it at the three cold positions, and the + // derived per-family row does at comparative — and its grounds are the + // researcher's reasoning about the frame, which no reading may see. + Name: "LEDGER-REFRAME", + Homes: []string{"repo:.abcd/work/issues/reframes/rfm-1.md"}, + Count: 1, + Why: "spc-2609020626048705: a reframe record is warm and reaches no reading; its " + + "exclusion is asserted in every manifest, by the ledger rows and by the floor's " + + "own reframe row", + }, { Name: "DEFINITION", Homes: []string{"repo:agents/cold-reading-widening.md"}, diff --git a/evals/coldreading_oracle_test.go b/evals/coldreading_oracle_test.go index 0555e324f..d67a89c89 100644 --- a/evals/coldreading_oracle_test.go +++ b/evals/coldreading_oracle_test.go @@ -100,10 +100,10 @@ var excludedFamilies = []excludedFamily{ Path: ".abcd/work/issues", Positions: []string{posWidening, posEntailment, posDetection}, Source: "itd-183 exclusion list: work/issues/ in every state, reading records and " + - "dispositions included, admission and selection grounds, and the lapse log. " + - "Not at comparative: adr-2609021016272867 admits one derived widening run's " + - "items there, so the container row withdraws and the six rows below name each " + - "family individually — a narrower assertion, not a weaker one", + "dispositions included, admission and selection grounds, reframe records, and the " + + "lapse log. Not at comparative: adr-2609021016272867 admits one derived widening " + + "run's items there, so the container row withdraws and the seven rows below name " + + "each family individually — a narrower assertion, not a weaker one", }, // The comparative position's ledger rows, one per family. They mirror the // rows the assembler derives from the ledger's own directory list, and they @@ -141,6 +141,13 @@ var excludedFamilies = []excludedFamily{ Positions: []string{posComparative}, Source: "adr-2609021016272867: a surprise is the researcher's own act, recorded warm", }, + { + Path: ".abcd/work/issues/reframes", + Positions: []string{posComparative}, + Source: "spc-2609020626048705: a reframe record is the researcher's pointer to a " + + "rewrite of the frame, warm at every position; its directory joins the ledger's " + + "list and so the comparative rows by derivation", + }, {Path: ".abcd/work/DECISIONS.md", Source: "itd-183 assembler rule 1: .abcd/ is excluded but for what the include list names"}, { Path: ".abcd/development/readings", @@ -631,16 +638,16 @@ func requireOracleTables(t *testing.T) { got int want int }{ - {"sentinelClasses", len(sentinelClasses), 21}, + {"sentinelClasses", len(sentinelClasses), 22}, {"carriers", len(carriers), 19}, {"materialClasses", len(materialClasses), 11}, {"holes", len(holes), 3}, {"refusals", len(refusals), 8}, {"excludedKeys", len(excludedKeys), 2}, {"excludedHeadings", len(excludedHeadings), 4}, - {"excludedFamilies", len(excludedFamilies), 22}, + {"excludedFamilies", len(excludedFamilies), 23}, {"admittedRecordPaths", len(admittedRecordPaths), 13}, - {"coverage", len(coverage), 79}, + {"coverage", len(coverage), 80}, } { if tbl.got != tbl.want { t.Fatalf("the %s table holds %d row(s), and this eval is written against %d; "+ diff --git a/evals/testdata/cold-reading/baseline/.abcd/work/issues/reframes/rfm-1.md b/evals/testdata/cold-reading/baseline/.abcd/work/issues/reframes/rfm-1.md new file mode 100644 index 000000000..e5fbe533e --- /dev/null +++ b/evals/testdata/cold-reading/baseline/.abcd/work/issues/reframes/rfm-1.md @@ -0,0 +1,13 @@ +--- +schema_version: 1 +id: "rfm-1" +occasioned_by: "rdi-201" +construal_before: "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" +glossary_before: "3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d" +scope_before: "2e7d2c03a9507ae265ecf5b5356885a53393a2029d241394997265a1a25aefc6" +grounds: "ABCD-EVAL-SENTINEL-LEDGER-REFRAME: the reading sent the researcher back to the frame rather than to the artefact" +construal_after: "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4" +glossary_after: "3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d" +scope_after: "2e7d2c03a9507ae265ecf5b5356885a53393a2029d241394997265a1a25aefc6" +changed: ["construal"] +--- diff --git a/internal/core/capture/reframe.go b/internal/core/capture/reframe.go new file mode 100644 index 000000000..59008a3a3 --- /dev/null +++ b/internal/core/capture/reframe.go @@ -0,0 +1,928 @@ +package capture + +// The reframe verb (itd-2609020625402518, spc-2609020626048705). +// +// A reframe occasioned by a reading is recorded as a reframe: one record under +// reframes/rfm-N.md naming what occasioned it, the SHA-256 of each of the +// frame's three committed surfaces before and after the rewrite, which of them +// changed, and the grounds. The frame is the framing as it presently stands, +// which adr-55 enumerates as three committed surfaces and adr-2609021016288378 +// fixes: the framing chapter's construal section, the committed glossary terms +// and the committed scope. The record carries nothing of any surface's text — +// the abandoned framing stays on the local side — so it can show that a reframe +// happened, when, why and where, without committing what it replaced. +// +// The verb reads the surfaces itself, at HEAD, in the working tree and in the +// surfaces' history, so the operator supplies no hash. Written after the +// rewrite's commit it is one write; written before it, it is two: `--open` +// records the before half, and `--complete rfm-N` finishes it once the rewrite +// is committed. The join to the occasion is the operator's assertion, checked +// in one respect only: the occasion predates the rewrite. + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/readingitem" + "github.com/intentdriven/abcd/internal/core/recordid" + "github.com/intentdriven/abcd/internal/core/site" + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/gitutil" +) + +// FrameSurface is one of the frame's committed surfaces: its name in the +// record, its repo-relative path, and, for a surface that is one section of a +// chapter, the H2 heading that section is titled. +type FrameSurface struct { + Name string + Path string + Heading string +} + +// FrameSurfaces is the frame, as ONE table (the intent's first scope +// condition): the paths and the heading are constants, and a repository whose +// frame lives elsewhere is outside this verb's scope. The order is the +// record's, and the names are issueschema.FrameSurfaceNames. +var FrameSurfaces = []FrameSurface{ + {Name: "construal", Path: ".abcd/development/brief/01-product/06-framing.md", Heading: "Construal"}, + {Name: "glossary", Path: ".abcd/development/brief/glossary"}, + {Name: "scope", Path: ".abcd/development/brief/01-product/04-scope.md"}, +} + +// Frame is one frame state: the fingerprint of each surface. +type Frame struct { + Construal string `json:"construal"` + Glossary string `json:"glossary"` + Scope string `json:"scope"` +} + +// of returns the fingerprint of the named surface. +func (f Frame) of(name string) string { + switch name { + case "construal": + return f.Construal + case "glossary": + return f.Glossary + case "scope": + return f.Scope + } + return "" +} + +// moved names the surfaces whose fingerprints differ between f and g, in the +// record's order. +func (f Frame) moved(g Frame) []string { + var out []string + for _, n := range issueschema.FrameSurfaceNames { + if f.of(n) != g.of(n) { + out = append(out, n) + } + } + return out +} + +// String renders the triple for a refusal that has to name it. +func (f Frame) String() string { + return fmt.Sprintf("construal %s, glossary %s, scope %s", f.Construal, f.Glossary, f.Scope) +} + +// The three halves a reframe write can be. +const ( + ReframeHalfWhole = "whole" + ReframeHalfOpen = "open" + ReframeHalfCompleted = "completed" +) + +// frameHistoryBound is how many commits touching any of the three surfaces a +// walk reads back from HEAD. A search that reaches it without finding what it +// looks for is refused rather than extended. A variable so a test can reach it +// with a short history. +var frameHistoryBound = 64 + +// ReframeRequest records one reframe, or completes an open one. +type ReframeRequest struct { + RepoRoot string + IssuesRoot string + // OccasionedBy is the reading item, disposition or surprise that occasioned + // the reframe (rdi-N, dsp-N or srp-N), and nothing else. + OccasionedBy string + // Grounds is why the frame moved, held to the grounds floor. + Grounds string + // Open records the first half, before the rewrite is committed. + Open bool + // Complete names the open record (rfm-N) to finish after the commit. It + // takes no occasion, ground or --open: those are the first half's. + Complete string +} + +// ReframeResult is the outcome of a successful Reframe. +type ReframeResult struct { + ID string `json:"id"` + Path string `json:"path"` + OccasionedBy string `json:"occasioned_by"` + Before Frame `json:"before"` + After Frame `json:"after"` + Changed []string `json:"changed"` + // Half says which half this invocation wrote: whole, open or completed. + Half string `json:"half"` + // Commits is how many commits touching the frame the walk crossed between + // the before and the after state. + Commits int `json:"commits"` + Redacted int `json:"redacted,omitempty"` + Degraded string `json:"redaction_degraded,omitempty"` +} + +// --- the fingerprints --- + +// normaliseSurface is the one normalisation every fingerprint applies: line +// endings to LF, trailing whitespace at the end of the text removed, and +// leading blank lines dropped. The trailing trim is what makes the three +// readers agree: git's output reaches this package with its trailing +// whitespace trimmed, and a file read from the working tree keeps it. +func normaliseSurface(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + s = strings.TrimRight(s, " \t\n") + for { + nl := strings.IndexByte(s, '\n') + if nl < 0 || strings.TrimSpace(s[:nl]) != "" { + break + } + s = s[nl+1:] + } + if strings.TrimSpace(s) == "" { + return "" + } + return s +} + +func sumHex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// ConstrualFingerprint fingerprints the framing chapter's construal section: +// the body under the one H2 titled `Construal`, down to the next heading of +// level two or shallower or the end of the file, line endings normalised and +// blank edges trimmed. The heading itself is a constant and is not hashed. A +// not-yet-real marker the section opens with is part of the section: a change +// to it is a change to what the construal states about itself. +// +// A chapter with no such section, or with more than one, is refused by name. +func ConstrualFingerprint(doc string) (string, error) { + heading := FrameSurfaces[0].Heading + body, _ := site.StripFrontmatter(strings.ReplaceAll(strings.ReplaceAll(doc, "\r\n", "\n"), "\r", "\n")) + secs, err := site.Sections(FrameSurfaces[0].Path, body, 0) + if err != nil { + return "", fmt.Errorf("the framing chapter cannot be read into sections: %w", err) + } + at := -1 + count := 0 + for i, s := range secs { + if s.Level == 2 && s.Title == heading { + count++ + at = i + } + } + switch count { + case 0: + return "", fmt.Errorf("the framing chapter %s carries no H2 section titled %q; the construal is that section, so there is nothing to fingerprint", + FrameSurfaces[0].Path, heading) + case 1: + default: + return "", fmt.Errorf("the framing chapter %s carries %d H2 sections titled %q; the construal is one section, and which of them is the frame is not a question this verb answers", + FrameSurfaces[0].Path, count, heading) + } + lines := strings.Split(body, "\n") + start := secs[at].Line // the line after the heading, 0-based + end := len(lines) + for _, s := range secs[at+1:] { + if s.Level >= 1 && s.Level <= 2 { + end = s.Line - 1 + break + } + } + return sumHex([]byte(normaliseSurface(strings.Join(lines[start:end], "\n")))), nil +} + +// GlossaryFingerprint fingerprints the committed glossary terms: every `.md` +// file handed to it except each README.md (the index is a render of the terms, +// held by core/glossary) and the _template.md scaffold, sorted by path, as the +// SHA-256 over path, NUL, normalised content, NUL for each. A term added, +// removed, renamed or edited moves it; an index regeneration does not. Paths +// are repo-relative and slash-separated, so every reader keys them alike. +func GlossaryFingerprint(files map[string][]byte) string { + paths := make([]string, 0, len(files)) + for p := range files { + if isGlossaryTerm(p) { + paths = append(paths, p) + } + } + sort.Strings(paths) + h := sha256.New() + for _, p := range paths { + h.Write([]byte(p)) + h.Write([]byte{0}) + h.Write([]byte(normaliseSurface(string(files[p])))) + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// isGlossaryTerm reports whether a glossary path is a term rather than an +// index or the scaffold. +func isGlossaryTerm(p string) bool { + base := path.Base(p) + return strings.HasSuffix(base, ".md") && base != "README.md" && base != "_template.md" +} + +// ScopeFingerprint fingerprints the committed scope chapter whole, after its +// frontmatter is stripped and its line endings normalised. +func ScopeFingerprint(doc string) string { + body, _ := site.StripFrontmatter(strings.ReplaceAll(strings.ReplaceAll(doc, "\r\n", "\n"), "\r", "\n")) + return sumHex([]byte(normaliseSurface(body))) +} + +// --- the readers --- + +// frameSurfacePaths are the three paths a history walk is limited to. +func frameSurfacePaths() []string { + out := make([]string, 0, len(FrameSurfaces)) + for _, s := range FrameSurfaces { + out = append(out, s.Path) + } + return out +} + +// fingerprintFrame composes the triple from the three surfaces' content. +func fingerprintFrame(framing, scope string, glossary map[string][]byte) (Frame, error) { + c, err := ConstrualFingerprint(framing) + if err != nil { + return Frame{}, err + } + return Frame{Construal: c, Glossary: GlossaryFingerprint(glossary), Scope: ScopeFingerprint(scope)}, nil +} + +// blobCache holds blob contents by object id, so a walk over many commits +// reads each distinct blob once. +type blobCache map[string]string + +// frameAtCommit fingerprints the three surfaces as rev holds them, reading the +// tree listing once and each blob through the cache. +func frameAtCommit(repoRoot, rev string, cache blobCache) (Frame, error) { + args := append([]string{"ls-tree", "-r", "-z", rev, "--"}, frameSurfacePaths()...) + out, err := gitutil.RunCapped(repoRoot, maxStatusBytes, args...) + if err != nil { + return Frame{}, fmt.Errorf("cannot list the frame at %s: %w", rev, err) + } + blobs := map[string]string{} + for _, rec := range strings.Split(out, "\x00") { + meta, p, ok := strings.Cut(rec, "\t") + if !ok { + continue + } + f := strings.Fields(meta) + if len(f) != 3 || f[1] != "blob" { + continue + } + blobs[p] = f[2] + } + read := func(oid string) (string, error) { + if v, ok := cache[oid]; ok { + return v, nil + } + v, err := gitutil.RunCapped(repoRoot, issueschema.RecordReadLimit, "cat-file", "blob", oid) + if err != nil { + return "", err + } + cache[oid] = v + return v, nil + } + chapter := func(s FrameSurface) (string, error) { + oid, ok := blobs[s.Path] + if !ok { + return "", fmt.Errorf("the %s surface %s is absent at %s", s.Name, s.Path, shortRev(rev)) + } + return read(oid) + } + framing, err := chapter(FrameSurfaces[0]) + if err != nil { + return Frame{}, err + } + scope, err := chapter(FrameSurfaces[2]) + if err != nil { + return Frame{}, err + } + glossary := map[string][]byte{} + prefix := FrameSurfaces[1].Path + "/" + for p, oid := range blobs { + if !strings.HasPrefix(p, prefix) || !isGlossaryTerm(p) { + continue + } + v, err := read(oid) + if err != nil { + return Frame{}, err + } + glossary[p] = []byte(v) + } + f, err := fingerprintFrame(framing, scope, glossary) + if err != nil { + return Frame{}, fmt.Errorf("at %s: %w", shortRev(rev), err) + } + return f, nil +} + +// frameInWorkingTree fingerprints the three surfaces as the working tree holds +// them, through the guarded read the ledger uses. The glossary is every file +// git tracks under its directory plus every untracked one it does not ignore, +// so a term added and not yet committed is a change, and a tracked term deleted +// from the tree is absent. +func frameInWorkingTree(repoRoot string) (Frame, error) { + chapter := func(s FrameSurface) (string, error) { + return readRecordGuarded(filepath.Join(repoRoot, filepath.FromSlash(s.Path))) + } + framing, err := chapter(FrameSurfaces[0]) + if err != nil { + return Frame{}, fmt.Errorf("the %s surface cannot be read from the working tree: %w", FrameSurfaces[0].Name, err) + } + scope, err := chapter(FrameSurfaces[2]) + if err != nil { + return Frame{}, fmt.Errorf("the %s surface cannot be read from the working tree: %w", FrameSurfaces[2].Name, err) + } + out, err := gitutil.RunCapped(repoRoot, maxStatusBytes, + "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", FrameSurfaces[1].Path) + if err != nil { + return Frame{}, fmt.Errorf("cannot list the glossary in the working tree: %w", err) + } + glossary := map[string][]byte{} + for _, p := range strings.Split(out, "\x00") { + if p == "" || !isGlossaryTerm(p) { + continue + } + v, err := readRecordGuarded(filepath.Join(repoRoot, filepath.FromSlash(p))) + if err != nil { + if os.IsNotExist(err) { + continue + } + return Frame{}, fmt.Errorf("the glossary term %s cannot be read: %w", p, err) + } + glossary[p] = []byte(v) + } + return fingerprintFrame(framing, scope, glossary) +} + +// frameWalk is the surfaces' bounded history from HEAD backwards: the commits +// touching any of the three surfaces, newest first, each fingerprinted on +// demand. A frame state is the triple after that commit. +type frameWalk struct { + repoRoot string + commits []string + frames map[int]Frame + errs map[int]error + cache blobCache +} + +// frameHistory reads the bounded list of commits touching the frame. +func frameHistory(repoRoot string, cache blobCache) (*frameWalk, error) { + args := append([]string{"log", "--format=%H", "-n", fmt.Sprint(frameHistoryBound), "HEAD", "--"}, frameSurfacePaths()...) + out, err := gitutil.RunCapped(repoRoot, maxStatusBytes, args...) + if err != nil { + return nil, fmt.Errorf("cannot read the frame's history: %w", err) + } + w := &frameWalk{repoRoot: repoRoot, frames: map[int]Frame{}, errs: map[int]error{}, cache: cache} + for _, ln := range strings.Split(out, "\n") { + if ln = strings.TrimSpace(ln); ln != "" { + w.commits = append(w.commits, ln) + } + } + return w, nil +} + +// at fingerprints the frame after commit i. +func (w *frameWalk) at(i int) (Frame, error) { + if f, ok := w.frames[i]; ok { + return f, nil + } + if err, ok := w.errs[i]; ok { + return Frame{}, err + } + f, err := frameAtCommit(w.repoRoot, w.commits[i], w.cache) + if err != nil { + w.errs[i] = err + return Frame{}, err + } + w.frames[i] = f + return f, nil +} + +// full reports whether the walk read as many commits as the bound allows, so a +// search that found nothing may have stopped short of what it sought. +func (w *frameWalk) full() bool { return len(w.commits) >= frameHistoryBound } + +// searched renders how far a fruitless walk went, for its refusal. +func (w *frameWalk) searched() string { + if w.full() { + return fmt.Sprintf("the walk reached its bound of %d commits touching the frame", frameHistoryBound) + } + return fmt.Sprintf("the walk read the whole history, %d commit(s) touching the frame", len(w.commits)) +} + +func shortRev(rev string) string { + if len(rev) > 12 { + return rev[:12] + } + return rev +} + +// --- the verb --- + +// Reframe writes one reframe record, its first half, or its second half. +// +// The whole write (no flag) requires the working tree to match HEAD, walks +// the surfaces' history to the previous distinct committed state, and writes +// both halves at once. `Open` writes the before half from HEAD's triple. +// `Complete` finishes an open record once HEAD's triple differs from its before +// triple, walking back until it finds that triple within the bound. Every +// refusal writes nothing. +func Reframe(req ReframeRequest) (ReframeResult, error) { + repoRoot, issuesRoot, err := resolveRoots(req.RepoRoot, req.IssuesRoot) + if err != nil { + return ReframeResult{}, err + } + if req.Complete != "" { + return completeReframe(repoRoot, issuesRoot, req) + } + occasion := req.OccasionedBy + if !issueschema.ValidReframeOccasion(occasion) { + return ReframeResult{}, fmt.Errorf("%w: --occasioned-by %q is not a handle of %s; a reframe is keyed to the reading record that occasioned it, never to prose (nothing written)", + ErrMalformedFrontmatter, occasion, reframeOccasionList()) + } + ground, redacted, degraded, err := requireFreeGrounds(repoRoot, "reframe", req.Grounds) + if err != nil { + return ReframeResult{}, err + } + occPath, err := resolveReframeOccasion(repoRoot, occasion) + if err != nil { + return ReframeResult{}, err + } + occCommit, err := occasionCommit(repoRoot, occasion, occPath) + if err != nil { + return ReframeResult{}, err + } + + cache := blobCache{} + head, err := frameAtCommit(repoRoot, "HEAD", cache) + if err != nil { + return ReframeResult{}, fmt.Errorf("%w: the frame at HEAD cannot be fingerprinted: %v (nothing written)", ErrInvariantViolation, err) + } + result := ReframeResult{OccasionedBy: occasion, Redacted: redacted, Degraded: degraded} + if req.Open { + // The first half: the rewrite is not yet committed, so the occasion + // being committed at HEAD — which occasionCommit established by finding + // it in HEAD's history — is the whole of the predate check. + result.Half, result.Before = ReframeHalfOpen, head + } else { + if err := requireWorkingTreeAtHead(repoRoot, head, "commit the rewrite, or record the first half with --open"); err != nil { + return ReframeResult{}, err + } + walk, err := frameHistory(repoRoot, cache) + if err != nil { + return ReframeResult{}, err + } + i := 0 + for ; i < len(walk.commits); i++ { + f, err := walk.at(i) + if err != nil { + return ReframeResult{}, fmt.Errorf("%w: the frame's previous state cannot be fingerprinted: %v; there is no prior committed state to record against (nothing written)", + ErrInvariantViolation, err) + } + if f != head { + break + } + } + if i == len(walk.commits) || i == 0 { + return ReframeResult{}, fmt.Errorf("%w: the frame at HEAD matches no prior committed state, so there is no reframe to record (%s; nothing written)", + ErrInvariantViolation, walk.searched()) + } + before, _ := walk.at(i) + if err := requirePredates(repoRoot, occasion, occCommit, walk.commits[i-1]); err != nil { + return ReframeResult{}, err + } + result.Half, result.Before, result.After = ReframeHalfWhole, before, head + result.Changed, result.Commits = before.moved(head), i + } + + if err := mutationPreamble(repoRoot, issuesRoot); err != nil { + return ReframeResult{}, err + } + err = withLedgerLock(repoRoot, issuesRoot, func() error { + // The occasion is resolved again where the write is decided. + if _, err := resolveReframeOccasion(repoRoot, occasion); err != nil { + return err + } + if req.Open { + open, err := openReframes(issuesRoot) + if err != nil { + return err + } + if len(open) > 0 { + return fmt.Errorf("%w: %s is open; a second open record could be completed against the wrong rewrite, so commit the rewrite and run `abcd capture reframe --complete %s` first (nothing written)", + ErrInvariantViolation, strings.Join(open, ", "), open[0]) + } + } + id, err := minter.Mint(issueschema.ReframeFamily) + if err != nil { + return err + } + fields, fm := reframeFields(id, occasion, ground, result) + if err := validateReframeStrict(fm); err != nil { + return err + } + content, err := buildIssueText(fields, "") + if err != nil { + return err + } + dir := filepath.Join(issuesRoot, issueschema.ReframesDir) + if err := safeMkdirLeaf(dir); err != nil { + return err + } + p := filepath.Join(dir, id+".md") + if err := refuseExistingRecord(p, id); err != nil { + return err + } + if err := writeReadingRecord(ledgerBase(repoRoot, issuesRoot), p, []byte(content)); err != nil { + return err + } + result.ID, result.Path = id, fsutil.RepoRel(repoRoot, p) + return nil + }) + if err != nil { + return ReframeResult{}, err + } + return result, nil +} + +// completeReframe is the second half: it finishes an open record once the +// rewrite is committed. +func completeReframe(repoRoot, issuesRoot string, req ReframeRequest) (ReframeResult, error) { + id := req.Complete + if req.OccasionedBy != "" || strings.TrimSpace(req.Grounds) != "" || req.Open { + return ReframeResult{}, fmt.Errorf("%w: --complete takes the record id alone; the occasion and the ground are the first half's, and the record already carries them (nothing written)", + ErrMalformedFrontmatter) + } + if !recordid.ValidReframeID(id) { + return ReframeResult{}, fmt.Errorf("%w: --complete %q does not match ^%s-[0-9]+$ (nothing written)", ErrMalformedFrontmatter, id, issueschema.ReframeFamily) + } + recPath, fm, _, err := readReframeRecord(issuesRoot, id) + if err != nil { + return ReframeResult{}, err + } + before, err := openBefore(id, fm) + if err != nil { + return ReframeResult{}, err + } + occasion := asString(fm["occasioned_by"]) + occPath, err := resolveReframeOccasion(repoRoot, occasion) + if err != nil { + return ReframeResult{}, err + } + occCommit, err := occasionCommit(repoRoot, occasion, occPath) + if err != nil { + return ReframeResult{}, err + } + + cache := blobCache{} + head, err := frameAtCommit(repoRoot, "HEAD", cache) + if err != nil { + return ReframeResult{}, fmt.Errorf("%w: the frame at HEAD cannot be fingerprinted: %v (nothing written)", ErrInvariantViolation, err) + } + if err := requireWorkingTreeAtHead(repoRoot, head, "commit the rewrite, then complete "+id); err != nil { + return ReframeResult{}, err + } + if head == before { + return ReframeResult{}, fmt.Errorf("%w: the frame at HEAD is still the state the record opened against; nothing was rewritten, so commit the rewrite before completing %s (nothing written)", + ErrInvariantViolation, id) + } + walk, err := frameHistory(repoRoot, cache) + if err != nil { + return ReframeResult{}, err + } + found := -1 + for i := range walk.commits { + // A state that cannot be fingerprinted is not the one sought; the walk + // goes on past it, and a walk that finds nothing refuses below. + if f, err := walk.at(i); err == nil && f == before { + found = i + break + } + } + if found < 1 { + return ReframeResult{}, fmt.Errorf("%w: the surfaces' history no longer contains the state %s opened against, so the rewrite cannot be paired with it: before %s; HEAD %s (%s; nothing written)", + ErrInvariantViolation, id, before, head, walk.searched()) + } + if err := requirePredates(repoRoot, occasion, occCommit, walk.commits[found-1]); err != nil { + return ReframeResult{}, err + } + + result := ReframeResult{ + ID: id, Path: fsutil.RepoRel(repoRoot, recPath), OccasionedBy: occasion, + Before: before, After: head, Changed: before.moved(head), + Half: ReframeHalfCompleted, Commits: found, + } + err = withLedgerLock(repoRoot, issuesRoot, func() error { + // Read again where the write is decided: the record must still be open, + // against the same before triple the walk paired. + _, fm, content, err := readReframeRecord(issuesRoot, id) + if err != nil { + return err + } + again, err := openBefore(id, fm) + if err != nil { + return err + } + if again != before { + return fmt.Errorf("%w: %s changed while it was being completed (nothing written)", ErrInvariantViolation, id) + } + for _, kv := range []struct{ key, val string }{ + {"construal_after", head.Construal}, {"glossary_after", head.Glossary}, {"scope_after", head.Scope}, + } { + if content, err = setScalarField(content, kv.key, kv.val); err != nil { + return err + } + } + if content, err = setListField(content, "changed", result.Changed); err != nil { + return err + } + done, _, err := parseFrontmatterAndBody(content) + if err != nil { + return err + } + if err := validateReframeStrict(done); err != nil { + return err + } + return writeReadingRecord(ledgerBase(repoRoot, issuesRoot), recPath, []byte(content)) + }) + if err != nil { + return ReframeResult{}, err + } + return result, nil +} + +// requireWorkingTreeAtHead refuses a working tree whose surfaces differ from +// HEAD's, naming the first one that does. +func requireWorkingTreeAtHead(repoRoot string, head Frame, remedy string) error { + wt, err := frameInWorkingTree(repoRoot) + if err != nil { + return fmt.Errorf("%w: %v (nothing written)", ErrInvariantViolation, err) + } + if moved := head.moved(wt); len(moved) > 0 { + verb := "has" + if len(moved) > 1 { + verb = "have" + } + return fmt.Errorf("%w: the %s %s uncommitted changes; %s (nothing written)", + ErrInvariantViolation, strings.Join(moved, " and the "), verb, remedy) + } + return nil +} + +// resolveReframeOccasion resolves the occasion through the one occasion +// resolver the reading chain shares. +func resolveReframeOccasion(repoRoot, occasion string) (string, error) { + fams := make([]readingitem.Family, 0, len(issueschema.ReframeOccasionFamilies)) + for _, f := range issueschema.ReframeOccasionFamilies { + fams = append(fams, readingitem.Family(f)) + } + p, err := readingitem.ResolveOccasion(repoRoot, occasion, fams...) + if err != nil { + return "", fmt.Errorf("--occasioned-by %s does not resolve: %w (nothing written)", occasion, wrapLocatorErr(err)) + } + return p, nil +} + +// occasionCommit is the commit that added the occasion's record, found in +// HEAD's history. An occasion no commit added is refused: a reframe cannot be +// occasioned by a record that does not yet exist in history. +func occasionCommit(repoRoot, occasion, abs string) (string, error) { + rel := fsutil.RepoRel(repoRoot, abs) + out, err := gitutil.RunCapped(repoRoot, 4096, "log", "--diff-filter=A", "--format=%H", "-n", "1", "HEAD", "--", rel) + if err != nil { + return "", fmt.Errorf("cannot read the history of the occasion %s: %w (nothing written)", occasion, err) + } + if c := strings.TrimSpace(out); c != "" { + return c, nil + } + return "", fmt.Errorf("%w: the occasion %s is not committed; a reframe cannot be occasioned by a record that does not yet exist in history (nothing written)", + ErrInvariantViolation, occasion) +} + +// requirePredates holds the one check the join carries: the commit that added +// the occasion is a strict ancestor of the rewrite. It is a floor, not a proof +// that the occasion caused the rewrite. +func requirePredates(repoRoot, occasion, occCommit, rewrite string) error { + if occCommit != rewrite { + ok, err := gitutil.IsAncestor(repoRoot, occCommit, rewrite) + if err != nil { + return fmt.Errorf("cannot order the occasion %s against the rewrite: %w (nothing written)", occasion, err) + } + if ok { + return nil + } + } + return fmt.Errorf("%w: the occasion %s was committed in %s, which does not precede the rewrite %s; a reframe cannot be occasioned by what came later (nothing written)", + ErrInvariantViolation, occasion, shortRev(occCommit), shortRev(rewrite)) +} + +// readReframeRecord reads one reframe record by id from the flat store, +// refusing a symlinked store or leaf, and returns its path, parsed frontmatter +// and raw content. +func readReframeRecord(issuesRoot, id string) (string, map[string]any, string, error) { + dir := filepath.Join(issuesRoot, issueschema.ReframesDir) + if err := refuseSymlinkedDir(dir); err != nil { + return "", nil, "", err + } + p := filepath.Join(dir, id+".md") + content, err := readRecordGuarded(p) + if err != nil { + if os.IsNotExist(err) { + return "", nil, "", fmt.Errorf("%w: %s is not a reframe this ledger holds (nothing written)", ErrUnknownIssueID, id) + } + return "", nil, "", err + } + fm, _, err := parseFrontmatterAndBody(content) + if err != nil { + return "", nil, "", fmt.Errorf("%w: %s does not parse: %v (nothing written)", ErrMalformedFrontmatter, id, err) + } + if err := validateReframeStrict(fm); err != nil { + return "", nil, "", fmt.Errorf("%s: %w (nothing written)", id, err) + } + return p, fm, content, nil +} + +// openBefore returns an open record's before triple, refusing a complete one. +func openBefore(id string, fm map[string]any) (Frame, error) { + if _, done := fm["construal_after"]; done { + return Frame{}, fmt.Errorf("%w: %s is already complete; a reframe record is completed once (nothing written)", ErrInvariantViolation, id) + } + return Frame{ + Construal: asString(fm["construal_before"]), + Glossary: asString(fm["glossary_before"]), + Scope: asString(fm["scope_before"]), + }, nil +} + +// openReframes lists the ids of the open reframe records: those carrying no +// after half. A record that cannot be read is refused by name rather than +// counted either way. +func openReframes(issuesRoot string) ([]string, error) { + dir := filepath.Join(issuesRoot, issueschema.ReframesDir) + if err := refuseSymlinkedDir(dir); err != nil { + return nil, err + } + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var open []string + for _, e := range entries { + id := strings.TrimSuffix(e.Name(), ".md") + if id == e.Name() || !recordid.ValidReframeID(id) { + continue + } + _, fm, _, err := readReframeRecord(issuesRoot, id) + if err != nil { + return nil, fmt.Errorf("cannot tell whether %s is open: %w", id, err) + } + if _, done := fm["construal_after"]; !done { + open = append(open, id) + } + } + sort.Strings(open) + return open, nil +} + +// reframeOccasionList renders the admitted families for a refusal. +func reframeOccasionList() string { + names := make([]string, 0, len(issueschema.ReframeOccasionFamilies)) + for _, f := range issueschema.ReframeOccasionFamilies { + names = append(names, f+"-N") + } + return strings.Join(names, ", ") +} + +// reframeFields assembles one reframe's frontmatter in the schema's order: the +// required set, then the after half where the write carries it — the order a +// completion leaves an opened record in, so the two spell one record alike. +func reframeFields(id, occasion, ground string, r ReframeResult) ([]kv, map[string]any) { + fields := []kv{ + {"schema_version", 1}, + {"id", id}, + {"occasioned_by", occasion}, + {"construal_before", r.Before.Construal}, + {"glossary_before", r.Before.Glossary}, + {"scope_before", r.Before.Scope}, + {"grounds", ground}, + } + if r.Half == ReframeHalfWhole { + fields = append(fields, + kv{"construal_after", r.After.Construal}, + kv{"glossary_after", r.After.Glossary}, + kv{"scope_after", r.After.Scope}, + kv{"changed", r.Changed}, + ) + } + fm := map[string]any{} + for _, f := range fields { + fm[f.key] = f.val + } + return fields, fm +} + +// validateReframeStrict holds a reframe to issueschema's declaration of the +// family: its closed key set, every required key present, the id and occasion +// well-formed, every fingerprint a 64-hex SHA-256, and the after half either +// wholly absent (open) or wholly present (complete) with `changed` naming +// exactly the surfaces whose fingerprints differ — never none of them. +func validateReframeStrict(fm map[string]any) error { + if err := requireSchemaVersion(fm); err != nil { + return err + } + for k := range fm { + if !issueschema.ReframeKnown[k] { + return fmt.Errorf("%w: unknown property %q on a reframe", ErrMalformedFrontmatter, k) + } + } + for _, key := range issueschema.ReframeRequired[1:] { + if err := requireNonBlankString(fm, key); err != nil { + return err + } + } + if id := asString(fm["id"]); !recordid.ValidReframeID(id) { + return fmt.Errorf("%w: id %q does not match ^%s-[0-9]+$", ErrMalformedFrontmatter, id, issueschema.ReframeFamily) + } + if occ := asString(fm["occasioned_by"]); !issueschema.ValidReframeOccasion(occ) { + return fmt.Errorf("%w: occasioned_by %q is not a handle of %s", ErrMalformedFrontmatter, occ, reframeOccasionList()) + } + var before, after Frame + present := 0 + for _, n := range issueschema.FrameSurfaceNames { + b := asString(fm[n+"_before"]) + if !issueschema.ValidFingerprint(b) { + return fmt.Errorf("%w: %s_before %q is not a 64-hex SHA-256 fingerprint", ErrMalformedFrontmatter, n, b) + } + setFrame(&before, n, b) + if v, ok := fm[n+"_after"]; ok { + present++ + a, isStr := v.(string) + if !isStr || !issueschema.ValidFingerprint(a) { + return fmt.Errorf("%w: %s_after %v is not a 64-hex SHA-256 fingerprint", ErrMalformedFrontmatter, n, v) + } + setFrame(&after, n, a) + } + } + changedRaw, hasChanged := fm["changed"] + switch { + case present == 0 && !hasChanged: + return nil // an open record + case present != len(issueschema.FrameSurfaceNames) || !hasChanged: + return fmt.Errorf("%w: a reframe's after half is the three after fingerprints and `changed` together, or none of them", ErrMalformedFrontmatter) + } + changed, ok := changedRaw.([]string) + if !ok { + return fmt.Errorf("%w: changed must be a list of surface names", ErrMalformedFrontmatter) + } + for _, c := range changed { + if !issueschema.ValidFrameSurface(c) { + return fmt.Errorf("%w: changed names %q, which is not one of %s", ErrMalformedFrontmatter, c, strings.Join(issueschema.FrameSurfaceNames, ", ")) + } + } + want := before.moved(after) + if len(want) == 0 { + return fmt.Errorf("%w: a completed reframe in which no surface moved records no reframe", ErrInvariantViolation) + } + if strings.Join(changed, ",") != strings.Join(want, ",") { + return fmt.Errorf("%w: changed %v does not name the surfaces whose fingerprints differ (%v)", ErrMalformedFrontmatter, changed, want) + } + return nil +} + +func setFrame(f *Frame, name, v string) { + switch name { + case "construal": + f.Construal = v + case "glossary": + f.Glossary = v + case "scope": + f.Scope = v + } +} diff --git a/internal/core/capture/reframe_test.go b/internal/core/capture/reframe_test.go new file mode 100644 index 000000000..a81577145 --- /dev/null +++ b/internal/core/capture/reframe_test.go @@ -0,0 +1,734 @@ +package capture + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/gittest" +) + +// The reframe record (itd-2609020625402518, spc-2609020626048705). + +const ( + fxFraming = ".abcd/development/brief/01-product/06-framing.md" + fxScope = ".abcd/development/brief/01-product/04-scope.md" + fxGlossary = ".abcd/development/brief/glossary" + + fxItem = "rdi-11" + fxDisposition = "dsp-5" + fxSurprise = "srp-7" + + reframeGround = "the detection reading showed the construal treated the ledger as the product rather than as the record" + + // A phrase that exists only in the ABANDONED construal. The record must + // never carry it: its text stays on the local side (adr-55). + fxOldPhrase = "the repository is a filing cabinet" +) + +// framingDoc renders a framing chapter whose Construal section states c. The +// section runs to the next H2, so the H3 under it is part of it and the H2 +// after it is not. +func framingDoc(c string) string { + return "---\nstatus: current\n---\n# Framing\n\nWhat the frame is for.\n\n## Construal\n\n" + c + + "\n\n### How it is held\n\nA subsection of the construal.\n\n## Consequences\n\nNot the construal.\n" +} + +func scopeDoc(s string) string { return "# Scope\n\n" + s + "\n" } + +// reframeFixture lays out a repository carrying the three frame surfaces and +// one occasion of each family, all committed, and returns it. +func reframeFixture(t *testing.T) *gittest.Repo { + t.Helper() + r := gittest.NewRepo(t) + r.Write(fxFraming, framingDoc("We are treating this as a gap: "+fxOldPhrase+".")) + r.Write(fxScope, scopeDoc("The scope is the eight phases.")) + r.Write(fxGlossary+"/README.md", "# Glossary\n\n- [term](core/term.md)\n") + r.Write(fxGlossary+"/_template.md", "# Template\n") + r.Write(fxGlossary+"/core/README.md", "# Core\n") + r.Write(fxGlossary+"/core/term.md", "# Term\n\nA term.\n") + r.Write(".abcd/work/issues/readings/rdg-1/"+fxItem+".md", "---\nid: rdi-11\n---\n") + r.Write(".abcd/work/issues/dispositions/"+fxItem+"/"+fxDisposition+".md", "---\nid: dsp-5\n---\n") + r.Write(".abcd/work/issues/surprises/"+fxSurprise+".md", "---\nid: srp-7\n---\n") + r.Commit("base: the frame and its occasions") + return r +} + +// rewriteConstrual commits a new construal. +func rewriteConstrual(r *gittest.Repo, c string) { + r.Write(fxFraming, framingDoc(c)) + r.Commit("rewrite the construal") +} + +func reframeReq(r *gittest.Repo, occasion string) ReframeRequest { + return ReframeRequest{RepoRoot: r.Root(), OccasionedBy: occasion, Grounds: reframeGround} +} + +// readReframe parses a written reframe record. +func readReframe(t *testing.T, r *gittest.Repo, rel string) (map[string]any, string) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(r.Root(), filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + fm, _, err := parseFrontmatterAndBody(string(raw)) + if err != nil { + t.Fatalf("parse %s: %v\n%s", rel, err, raw) + } + return fm, string(raw) +} + +// frameAt fingerprints the three surfaces as the repository holds them in the +// working tree, through the exported fingerprint functions alone — the test's +// independent derivation of what the verb must have written. +func frameAt(t *testing.T, r *gittest.Repo) Frame { + t.Helper() + read := func(rel string) string { + b, err := os.ReadFile(filepath.Join(r.Root(), filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + return string(b) + } + c, err := ConstrualFingerprint(read(fxFraming)) + if err != nil { + t.Fatal(err) + } + files := map[string][]byte{} + _ = filepath.Walk(filepath.Join(r.Root(), filepath.FromSlash(fxGlossary)), func(p string, fi os.FileInfo, err error) error { + if err != nil || fi.IsDir() { + return nil + } + rel, _ := filepath.Rel(r.Root(), p) + b, _ := os.ReadFile(p) + files[filepath.ToSlash(rel)] = b + return nil + }) + return Frame{Construal: c, Glossary: GlossaryFingerprint(files), Scope: ScopeFingerprint(read(fxScope))} +} + +func reframeFiles(t *testing.T, r *gittest.Repo) []string { + t.Helper() + entries, err := os.ReadDir(filepath.Join(r.Root(), ".abcd", "work", "issues", issueschema.ReframesDir)) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + var out []string + for _, e := range entries { + out = append(out, e.Name()) + } + return out +} + +func ledgerOf(r *gittest.Repo) string { return filepath.Join(r.Root(), ".abcd", "work", "issues") } + +// --- the fingerprints --- + +func TestConstrualFingerprintIsStableAcrossLineEndingsAndBlankEdges(t *testing.T) { + lf := framingDoc("We are treating this as a gap.") + base, err := ConstrualFingerprint(lf) + if err != nil { + t.Fatal(err) + } + if !issueschema.ValidFingerprint(base) { + t.Fatalf("fingerprint %q is not a 64-hex SHA-256", base) + } + for name, doc := range map[string]string{ + "CRLF": strings.ReplaceAll(lf, "\n", "\r\n"), + "blank lines around": strings.Replace(lf, "## Construal\n\n", "## Construal\n\n\n\n", 1), + "another section": strings.Replace(lf, "Not the construal.", "Something else entirely.", 1), + "no frontmatter": strings.TrimPrefix(lf, "---\nstatus: current\n---\n"), + } { + got, err := ConstrualFingerprint(doc) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if got != base { + t.Errorf("%s moved the construal fingerprint", name) + } + } + // The subsection is part of the section, and so is the not-yet-real marker. + for name, doc := range map[string]string{ + "subsection": strings.Replace(lf, "A subsection of the construal.", "A rewritten subsection.", 1), + "statement": framingDoc("We are treating this as something else."), + "marker": framingDoc("> **Status: NOT YET REAL.**\n\nWe are treating this as a gap."), + } { + got, err := ConstrualFingerprint(doc) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if got == base { + t.Errorf("a change to the %s did not move the construal fingerprint", name) + } + } +} + +func TestConstrualFingerprintRefusesAChapterWithoutTheSection(t *testing.T) { + if _, err := ConstrualFingerprint("# Framing\n\n## Consequences\n\ntext\n"); err == nil || !strings.Contains(err.Error(), "Construal") { + t.Errorf("no Construal section: err = %v, want a refusal naming it", err) + } + two := framingDoc("one") + "\n## Construal\n\ntwo\n" + if _, err := ConstrualFingerprint(two); err == nil || !strings.Contains(err.Error(), "2 H2 sections titled") { + t.Errorf("two Construal sections: err = %v, want a refusal naming the count", err) + } + // A Construal heading inside a fence is not a heading. + fenced := "# Framing\n\n```\n## Construal\n```\n" + if _, err := ConstrualFingerprint(fenced); err == nil { + t.Error("a fenced Construal heading was read as the section") + } + // An H3 titled Construal is not the section either. + if _, err := ConstrualFingerprint("# Framing\n\n### Construal\n\ntext\n"); err == nil { + t.Error("an H3 Construal was read as the H2 section") + } +} + +func TestGlossaryFingerprintIgnoresTheIndexAndMovesOnATerm(t *testing.T) { + files := map[string][]byte{ + fxGlossary + "/README.md": []byte("# Glossary\n"), + fxGlossary + "/_template.md": []byte("# Template\n"), + fxGlossary + "/core/README.md": []byte("# Core\n"), + fxGlossary + "/core/term.md": []byte("# Term\n\nA term.\n"), + } + base := GlossaryFingerprint(files) + if !issueschema.ValidFingerprint(base) { + t.Fatalf("fingerprint %q is not a 64-hex SHA-256", base) + } + clone := func() map[string][]byte { + out := map[string][]byte{} + for k, v := range files { + out[k] = v + } + return out + } + same := clone() + same[fxGlossary+"/README.md"] = []byte("# Glossary, regenerated\n\n- a new index line\n") + same[fxGlossary+"/core/README.md"] = []byte("# Core, regenerated\n") + same[fxGlossary+"/_template.md"] = []byte("# Template v2\n") + same[fxGlossary+"/core/term.md"] = []byte("# Term\r\n\r\nA term.\r\n") + if GlossaryFingerprint(same) != base { + t.Error("an index regeneration, a template edit or a line-ending change moved the glossary fingerprint") + } + edited := clone() + edited[fxGlossary+"/core/term.md"] = []byte("# Term\n\nA different term.\n") + added := clone() + added[fxGlossary+"/core/other.md"] = []byte("# Other\n") + removed := clone() + delete(removed, fxGlossary+"/core/term.md") + renamed := clone() + delete(renamed, fxGlossary+"/core/term.md") + renamed[fxGlossary+"/core/renamed.md"] = files[fxGlossary+"/core/term.md"] + for name, m := range map[string]map[string][]byte{"edited": edited, "added": added, "removed": removed, "renamed": renamed} { + if GlossaryFingerprint(m) == base { + t.Errorf("a term %s did not move the glossary fingerprint", name) + } + } +} + +func TestScopeFingerprintIsStableAcrossLineEndings(t *testing.T) { + lf := "---\nstatus: current\n---\n# Scope\n\nThe eight phases.\n" + base := ScopeFingerprint(lf) + if !issueschema.ValidFingerprint(base) { + t.Fatalf("fingerprint %q is not a 64-hex SHA-256", base) + } + if ScopeFingerprint(strings.ReplaceAll(lf, "\n", "\r\n")) != base { + t.Error("CRLF moved the scope fingerprint") + } + if ScopeFingerprint(strings.TrimPrefix(lf, "---\nstatus: current\n---\n")) != base { + t.Error("the frontmatter moved the scope fingerprint") + } + if ScopeFingerprint(strings.Replace(lf, "eight", "nine", 1)) == base { + t.Error("a scope edit did not move the scope fingerprint") + } +} + +// --- the whole write --- + +// ac-1: one record carrying the occasion, the before fingerprints of the +// previously committed state, the after fingerprints of HEAD, which surfaces +// changed, and the ground — and nothing of the abandoned text. +func TestReframeRecordsACommittedRewrite(t *testing.T) { + r := reframeFixture(t) + before := frameAt(t, r) + rewriteConstrual(r, "We are treating this as a record of judgement.") + after := frameAt(t, r) + + res, err := Reframe(reframeReq(r, fxItem)) + if err != nil { + t.Fatalf("Reframe: %v", err) + } + if res.Half != ReframeHalfWhole || res.Before != before || res.After != after { + t.Fatalf("result = %+v\nwant before %+v after %+v", res, before, after) + } + if !slices.Equal(res.Changed, []string{"construal"}) || res.Commits != 1 { + t.Fatalf("changed = %v commits = %d, want [construal] across 1", res.Changed, res.Commits) + } + if got := reframeFiles(t, r); len(got) != 1 || got[0] != res.ID+".md" { + t.Fatalf("reframes = %v, want exactly %s.md", got, res.ID) + } + fm, raw := readReframe(t, r, res.Path) + want := map[string]any{ + "schema_version": 1, "id": res.ID, "occasioned_by": fxItem, "grounds": reframeGround, + "construal_before": before.Construal, "glossary_before": before.Glossary, "scope_before": before.Scope, + "construal_after": after.Construal, "glossary_after": after.Glossary, "scope_after": after.Scope, + } + for k, v := range want { + if fm[k] != v { + t.Errorf("%s = %v, want %v", k, fm[k], v) + } + } + if ch, _ := fm["changed"].([]string); !slices.Equal(ch, []string{"construal"}) { + t.Errorf("changed = %v, want [construal]", fm["changed"]) + } + if len(fm) != len(issueschema.ReframeKnown) { + t.Errorf("the record carries %d keys, want the complete record's %d", len(fm), len(issueschema.ReframeKnown)) + } + if strings.Contains(raw, fxOldPhrase) || strings.Contains(raw, "record of judgement") { + t.Fatalf("the record carries a surface's text:\n%s", raw) + } + if !strings.HasPrefix(res.Path, ".abcd/work/issues/reframes/rfm-") { + t.Errorf("path = %q", res.Path) + } +} + +func TestReframeRecordsAGlossaryRewrite(t *testing.T) { + r := reframeFixture(t) + r.Write(fxGlossary+"/core/term.md", "# Term\n\nA sharper term.\n") + r.Commit("rewrite a glossary term") + res, err := Reframe(reframeReq(r, fxDisposition)) + if err != nil { + t.Fatalf("Reframe: %v", err) + } + if !slices.Equal(res.Changed, []string{"glossary"}) { + t.Fatalf("changed = %v, want [glossary]", res.Changed) + } + if res.Before.Construal != res.After.Construal || res.Before.Scope != res.After.Scope { + t.Errorf("an unmoved surface's fingerprints differ: %+v", res) + } +} + +func TestReframeRecordsAScopeRewrite(t *testing.T) { + r := reframeFixture(t) + r.Write(fxScope, scopeDoc("The scope is the nine phases.")) + r.Commit("rewrite the scope") + res, err := Reframe(reframeReq(r, fxSurprise)) + if err != nil { + t.Fatalf("Reframe: %v", err) + } + if !slices.Equal(res.Changed, []string{"scope"}) { + t.Fatalf("changed = %v, want [scope]", res.Changed) + } +} + +// ac-2: a frame with no distinct prior committed state has no reframe to +// record, and the refusal says so. +func TestReframeRefusesAFrameWithNoPriorState(t *testing.T) { + r := reframeFixture(t) + before := ledgerDigest(t, ledgerOf(r)) + _, err := Reframe(reframeReq(r, fxItem)) + if err == nil || !strings.Contains(err.Error(), "matches no prior committed state") { + t.Fatalf("err = %v, want the no-prior-state refusal", err) + } + if ledgerDigest(t, ledgerOf(r)) != before { + t.Fatal("a refused reframe changed the ledger") + } +} + +func TestReframeRefusesUncommittedChangesWithoutOpen(t *testing.T) { + r := reframeFixture(t) + rewriteConstrual(r, "A committed rewrite.") + for name, edit := range map[string]func(){ + "construal": func() { r.Write(fxFraming, framingDoc("An uncommitted rewrite.")) }, + "glossary": func() { r.Write(fxGlossary+"/core/new-term.md", "# New\n") }, + "scope": func() { r.Write(fxScope, scopeDoc("An uncommitted scope.")) }, + } { + t.Run(name, func(t *testing.T) { + r.Git("checkout", "--", ".") + r.Git("clean", "-fdq", "--", fxGlossary) + edit() + _, err := Reframe(reframeReq(r, fxItem)) + if err == nil || !strings.Contains(err.Error(), "the "+name+" has uncommitted changes") || !strings.Contains(err.Error(), "--open") { + t.Fatalf("err = %v, want a refusal naming the %s and --open", err, name) + } + }) + } + if got := reframeFiles(t, r); len(got) != 0 { + t.Fatalf("a refused reframe wrote %v", got) + } +} + +// --- the two halves --- + +func TestReframeOpensAHalfBeforeTheCommit(t *testing.T) { + r := reframeFixture(t) + head := frameAt(t, r) + r.Write(fxFraming, framingDoc("The rewrite, not yet committed.")) + req := reframeReq(r, fxItem) + req.Open = true + res, err := Reframe(req) + if err != nil { + t.Fatalf("Reframe --open: %v", err) + } + if res.Half != ReframeHalfOpen || res.Before != head || res.After != (Frame{}) || len(res.Changed) != 0 { + t.Fatalf("result = %+v, want the before triple of HEAD and nothing after", res) + } + fm, _ := readReframe(t, r, res.Path) + for _, k := range issueschema.ReframeAfter { + if _, ok := fm[k]; ok { + t.Errorf("an open record carries %q", k) + } + } + if fm["construal_before"] != head.Construal { + t.Errorf("construal_before = %v, want HEAD's %s", fm["construal_before"], head.Construal) + } +} + +func TestCompleteFinishesAnOpenRecord(t *testing.T) { + r := reframeFixture(t) + head := frameAt(t, r) + req := reframeReq(r, fxItem) + req.Open = true + opened, err := Reframe(req) + if err != nil { + t.Fatalf("open: %v", err) + } + r.Write(fxFraming, framingDoc("The rewrite, now committed.")) + r.Write(fxScope, scopeDoc("A scope that moved with it.")) + r.Git("add", fxFraming, fxScope) + r.Git("commit", "-m", "rewrite the construal and the scope") + after := frameAt(t, r) + + res, err := Reframe(ReframeRequest{RepoRoot: r.Root(), Complete: opened.ID}) + if err != nil { + t.Fatalf("complete: %v", err) + } + if res.Half != ReframeHalfCompleted || res.ID != opened.ID || res.Before != head || res.After != after { + t.Fatalf("result = %+v", res) + } + if !slices.Equal(res.Changed, []string{"construal", "scope"}) || res.Commits != 1 { + t.Fatalf("changed = %v commits = %d", res.Changed, res.Commits) + } + fm, _ := readReframe(t, r, opened.Path) + if fm["construal_after"] != after.Construal || fm["scope_after"] != after.Scope || fm["grounds"] != reframeGround { + t.Fatalf("completed record = %v", fm) + } + if got := reframeFiles(t, r); len(got) != 1 { + t.Fatalf("completion wrote a second record: %v", got) + } + // A complete record is not completed twice. + if _, err := Reframe(ReframeRequest{RepoRoot: r.Root(), Complete: opened.ID}); err == nil || !strings.Contains(err.Error(), "already complete") { + t.Fatalf("second completion: err = %v", err) + } +} + +func TestCompleteRefusesWhenNoSurfaceMoved(t *testing.T) { + r := reframeFixture(t) + req := reframeReq(r, fxItem) + req.Open = true + opened, err := Reframe(req) + if err != nil { + t.Fatal(err) + } + r.Write("unrelated.md", "an unrelated change\n") + r.Commit("unrelated") + before := ledgerDigest(t, ledgerOf(r)) + _, err = Reframe(ReframeRequest{RepoRoot: r.Root(), Complete: opened.ID}) + if err == nil || !strings.Contains(err.Error(), "still the state the record opened against") { + t.Fatalf("err = %v, want the nothing-rewritten refusal", err) + } + if ledgerDigest(t, ledgerOf(r)) != before { + t.Fatal("a refused completion changed the ledger") + } +} + +// A rewrite committed in two commits, and one brought in by a merge commit, +// pair the same way: the merge strategy does not decide the outcome. +func TestCompleteCrossesATwoCommitRewrite(t *testing.T) { + t.Run("two commits", func(t *testing.T) { + r := reframeFixture(t) + req := reframeReq(r, fxItem) + req.Open = true + opened, err := Reframe(req) + if err != nil { + t.Fatal(err) + } + rewriteConstrual(r, "First half of the rewrite.") + r.Write(fxGlossary+"/core/term.md", "# Term\n\nSecond half of the rewrite.\n") + r.Commit("rewrite a term") + res, err := Reframe(ReframeRequest{RepoRoot: r.Root(), Complete: opened.ID}) + if err != nil { + t.Fatalf("complete: %v", err) + } + if res.Commits != 2 || !slices.Equal(res.Changed, []string{"construal", "glossary"}) { + t.Fatalf("commits = %d changed = %v, want 2 and [construal glossary]", res.Commits, res.Changed) + } + }) + t.Run("a merge commit", func(t *testing.T) { + r := reframeFixture(t) + req := reframeReq(r, fxItem) + req.Open = true + opened, err := Reframe(req) + if err != nil { + t.Fatal(err) + } + // The open record is uncommitted ledger state; keep it out of the + // branch switch. + r.Git("checkout", "-q", "-b", "rewrite") + rewriteConstrual(r, "The rewrite on its own branch.") + r.Write(fxScope, scopeDoc("And a scope on that branch.")) + r.Git("add", fxScope) + r.Git("commit", "-q", "-m", "rewrite the scope") + r.Git("checkout", "-q", "main") + r.Write("unrelated.md", "main moved meanwhile\n") + r.Git("add", "unrelated.md") + r.Git("commit", "-q", "-m", "unrelated") + r.Git("merge", "-q", "--no-ff", "-m", "merge the rewrite", "rewrite") + res, err := Reframe(ReframeRequest{RepoRoot: r.Root(), Complete: opened.ID}) + if err != nil { + t.Fatalf("complete across a merge: %v", err) + } + if res.Commits < 2 || !slices.Equal(res.Changed, []string{"construal", "scope"}) { + t.Fatalf("commits = %d changed = %v", res.Commits, res.Changed) + } + }) +} + +func TestCompleteRefusesARewriteItCannotPair(t *testing.T) { + t.Run("a before state the history never held", func(t *testing.T) { + r := reframeFixture(t) + req := reframeReq(r, fxItem) + req.Open = true + opened, err := Reframe(req) + if err != nil { + t.Fatal(err) + } + // Hand-edit the before fingerprint to a state no commit ever held. + path := filepath.Join(r.Root(), filepath.FromSlash(opened.Path)) + raw, _ := os.ReadFile(path) + sum := sha256.Sum256([]byte("a frame nobody committed")) + phantom := hex.EncodeToString(sum[:]) + edited := strings.Replace(string(raw), opened.Before.Construal, phantom, 1) + if err := os.WriteFile(path, []byte(edited), 0o644); err != nil { + t.Fatal(err) + } + rewriteConstrual(r, "A rewrite.") + _, err = Reframe(ReframeRequest{RepoRoot: r.Root(), Complete: opened.ID}) + if err == nil || !strings.Contains(err.Error(), "no longer contains") || !strings.Contains(err.Error(), phantom) { + t.Fatalf("err = %v, want the unpairable refusal naming the before triple", err) + } + }) + t.Run("a surface rewritten beyond the bound", func(t *testing.T) { + orig := frameHistoryBound + frameHistoryBound = 2 + t.Cleanup(func() { frameHistoryBound = orig }) + r := reframeFixture(t) + req := reframeReq(r, fxItem) + req.Open = true + opened, err := Reframe(req) + if err != nil { + t.Fatal(err) + } + for _, c := range []string{"one", "two", "three"} { + rewriteConstrual(r, "Rewrite "+c+".") + } + before := ledgerDigest(t, ledgerOf(r)) + _, err = Reframe(ReframeRequest{RepoRoot: r.Root(), Complete: opened.ID}) + if err == nil || !strings.Contains(err.Error(), "bound") || !strings.Contains(err.Error(), opened.Before.Construal) { + t.Fatalf("err = %v, want a refusal naming the bound and the before triple", err) + } + if ledgerDigest(t, ledgerOf(r)) != before { + t.Fatal("a refused completion changed the ledger") + } + }) +} + +func TestReframeRefusesASecondOpenRecord(t *testing.T) { + r := reframeFixture(t) + req := reframeReq(r, fxItem) + req.Open = true + opened, err := Reframe(req) + if err != nil { + t.Fatal(err) + } + req.OccasionedBy = fxSurprise + _, err = Reframe(req) + if err == nil || !strings.Contains(err.Error(), opened.ID) || !strings.Contains(err.Error(), "open") { + t.Fatalf("err = %v, want a refusal naming the open %s", err, opened.ID) + } + if got := reframeFiles(t, r); len(got) != 1 { + t.Fatalf("reframes = %v, want the one open record", got) + } +} + +// --- the ground --- + +func TestReframeHoldsTheGroundToTheFloor(t *testing.T) { + r := reframeFixture(t) + rewriteConstrual(r, "A rewrite.") + for _, g := range []string{"", " ", "ok", "because"} { + req := reframeReq(r, fxItem) + req.Grounds = g + if _, err := Reframe(req); !errors.Is(err, ErrGroundsRefused) { + t.Errorf("ground %q: err = %v, want ErrGroundsRefused", g, err) + } + } + if got := reframeFiles(t, r); len(got) != 0 { + t.Fatalf("a refused ground wrote %v", got) + } +} + +func TestReframeRedactsTheGround(t *testing.T) { + r := reframeFixture(t) + rewriteConstrual(r, "A rewrite.") + home := os.Getenv("HOME") + req := reframeReq(r, fxItem) + req.Grounds = "the reading cited " + filepath.Join(home, "notes", "draft.md") + " which recast the whole construal" + res, err := Reframe(req) + if err != nil { + t.Fatalf("Reframe: %v", err) + } + if res.Redacted == 0 { + t.Fatal("Redacted = 0, want the home path counted") + } + _, raw := readReframe(t, r, res.Path) + if strings.Contains(raw, home) { + t.Fatalf("the committed reframe carries the caller's home root:\n%s", raw) + } +} + +// --- the occasion --- + +// ac-3: an occasion that does not resolve to a reading item, a disposition or +// a surprise this ledger holds refuses, writing nothing. +func TestReframeRefusesAnUnresolvableOccasion(t *testing.T) { + r := reframeFixture(t) + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "srp-9.md"), []byte("---\nid: srp-9\n---\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(outside, "srp-9.md"), filepath.Join(ledgerOf(r), "surprises", "srp-9.md")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + r.Commit("a symlinked surprise") + rewriteConstrual(r, "A rewrite.") + for _, occ := range []string{"rdi-99", "dsp-99", "srp-9", "iss-1", "adm-1", "a reading", ""} { + before := ledgerDigest(t, ledgerOf(r)) + if _, err := Reframe(reframeReq(r, occ)); err == nil { + t.Errorf("occasion %q: resolved, want a refusal", occ) + } + if ledgerDigest(t, ledgerOf(r)) != before { + t.Errorf("occasion %q: a refused reframe changed the ledger", occ) + } + } +} + +func TestReframeRefusesAnUncommittedOccasion(t *testing.T) { + r := reframeFixture(t) + rewriteConstrual(r, "A rewrite.") + r.Write(".abcd/work/issues/readings/rdg-1/rdi-12.md", "---\nid: rdi-12\n---\n") + _, err := Reframe(reframeReq(r, "rdi-12")) + if err == nil || !strings.Contains(err.Error(), "rdi-12 is not committed") { + t.Fatalf("err = %v, want the uncommitted-occasion refusal", err) + } + // The first half asks the same: the occasion must be committed at HEAD. + req := reframeReq(r, "rdi-12") + req.Open = true + if _, err := Reframe(req); err == nil || !strings.Contains(err.Error(), "rdi-12 is not committed") { + t.Fatalf("open: err = %v, want the uncommitted-occasion refusal", err) + } +} + +func TestReframeRefusesAnOccasionCommittedAfterTheRewrite(t *testing.T) { + t.Run("a later commit", func(t *testing.T) { + r := reframeFixture(t) + rewriteConstrual(r, "A rewrite.") + r.Write(".abcd/work/issues/readings/rdg-1/rdi-12.md", "---\nid: rdi-12\n---\n") + r.Commit("the occasion, after the rewrite") + _, err := Reframe(reframeReq(r, "rdi-12")) + if err == nil || !strings.Contains(err.Error(), "what came later") { + t.Fatalf("err = %v, want the predate refusal", err) + } + }) + t.Run("the same commit", func(t *testing.T) { + r := reframeFixture(t) + r.Write(fxFraming, framingDoc("A rewrite committed with its occasion.")) + r.Write(".abcd/work/issues/readings/rdg-1/rdi-12.md", "---\nid: rdi-12\n---\n") + r.Commit("the rewrite and the occasion at once") + _, err := Reframe(reframeReq(r, "rdi-12")) + if err == nil || !strings.Contains(err.Error(), "what came later") { + t.Fatalf("err = %v, want the predate refusal", err) + } + }) + t.Run("a completion", func(t *testing.T) { + r := reframeFixture(t) + req := reframeReq(r, fxItem) + req.Open = true + opened, err := Reframe(req) + if err != nil { + t.Fatal(err) + } + // The occasion's record is removed and re-added AFTER the rewrite, so + // the commit that added it no longer predates the rewrite. + r.Git("rm", "-q", ".abcd/work/issues/readings/rdg-1/"+fxItem+".md") + r.Git("commit", "-q", "-m", "drop the occasion") + rewriteConstrual(r, "A rewrite.") + r.Write(".abcd/work/issues/readings/rdg-1/"+fxItem+".md", "---\nid: rdi-11\n---\n") + r.Git("add", ".abcd/work/issues/readings") + r.Git("commit", "-q", "-m", "restore the occasion") + _, err = Reframe(ReframeRequest{RepoRoot: r.Root(), Complete: opened.ID}) + if err == nil || !strings.Contains(err.Error(), "what came later") { + t.Fatalf("err = %v, want the predate refusal", err) + } + }) +} + +// --- the lock --- + +func TestReframeSerialisesOnTheLedgerLock(t *testing.T) { + // Two first halves raced: the open-record check and the write are one + // decision under the lock, so exactly one lands and the other is refused + // naming the record that did. + r2 := reframeFixture(t) + var wg sync.WaitGroup + errs := make([]error, 2) + for i, occ := range []string{fxItem, fxSurprise} { + wg.Add(1) + go func(i int, occ string) { + defer wg.Done() + q := reframeReq(r2, occ) + q.Open = true + _, errs[i] = Reframe(q) + }(i, occ) + } + wg.Wait() + ok := 0 + for _, err := range errs { + switch { + case err == nil: + ok++ + case !strings.Contains(err.Error(), "is open"): + t.Errorf("the losing open was refused for another reason: %v", err) + } + } + if ok != 1 || len(reframeFiles(t, r2)) != 1 { + t.Fatalf("raced opens: errs = %v, records = %v, want exactly one open record", errs, reframeFiles(t, r2)) + } + + // And the id is minted under the lock. + r := reframeFixture(t) + held := mintLockProbe(t, r.Root(), ledgerOf(r)) + req := reframeReq(r, fxItem) + req.Open = true + if _, err := Reframe(req); err != nil { + t.Fatalf("Reframe: %v", err) + } + if len(*held) != 1 || !(*held)[0] { + t.Fatalf("mint lock probe = %v, want one mint under the lock", *held) + } +} diff --git a/internal/core/issueschema/admission.go b/internal/core/issueschema/admission.go index cab8acfdf..88cce9560 100644 --- a/internal/core/issueschema/admission.go +++ b/internal/core/issueschema/admission.go @@ -1,7 +1,5 @@ package issueschema -import "strings" - // The step-2 admission records (itd-189, spc-67). // // Declining a proposal costs nothing epistemically; ADMITTING one is where the @@ -93,25 +91,7 @@ var SurpriseOccasionFamilies = []string{ReadingItemFamily, AdmissionFamily, Disp // ValidSurpriseOccasion reports whether v is VERBATIM a handle of one of // SurpriseOccasionFamilies: the family's own prefix, one hyphen and digits, // with nothing around it. -func ValidSurpriseOccasion(v string) bool { - for _, f := range SurpriseOccasionFamilies { - rest, ok := strings.CutPrefix(v, f+"-") - if !ok || rest == "" { - continue - } - digits := true - for i := 0; i < len(rest); i++ { - if rest[i] < '0' || rest[i] > '9' { - digits = false - break - } - } - if digits { - return true - } - } - return false -} +func ValidSurpriseOccasion(v string) bool { return validHandleOf(SurpriseOccasionFamilies, v) } // SurpriseKnown is the surprise entry's allow-list. var SurpriseKnown = knownSet(SurpriseRequired) diff --git a/internal/core/issueschema/ledgerdirs.go b/internal/core/issueschema/ledgerdirs.go index 15602048d..1d32a7190 100644 --- a/internal/core/issueschema/ledgerdirs.go +++ b/internal/core/issueschema/ledgerdirs.go @@ -21,12 +21,12 @@ import "sort" // // It is DERIVED from the constants above it rather than restated, so a family // this package gains is in the list from the day its constant is declared. -// spc-2609020626048705's reframes directory joins it when that spec lands, by -// declaring its constant and adding it here in the same change. +// The reframes directory (spc-2609020626048705) joined by declaring its +// constant and adding it here in the same change. func LedgerDirs() []string { - out := make([]string, 0, len(StatusDirs)+4) + out := make([]string, 0, len(StatusDirs)+5) out = append(out, StatusDirs...) - return append(out, ReadingsDir, DispositionsDir, AdmissionsDir, SurprisesDir) + return append(out, ReadingsDir, DispositionsDir, AdmissionsDir, SurprisesDir, ReframesDir) } // ReadingsRecordDir is the DURABLE home of a run's own artefacts — the promoted diff --git a/internal/core/issueschema/ledgerdirs_test.go b/internal/core/issueschema/ledgerdirs_test.go index 76bf4a5b7..a45417f7e 100644 --- a/internal/core/issueschema/ledgerdirs_test.go +++ b/internal/core/issueschema/ledgerdirs_test.go @@ -21,6 +21,7 @@ func TestLedgerDirsNamesEveryConstant(t *testing.T) { issueschema.DispositionsDir, issueschema.AdmissionsDir, issueschema.SurprisesDir, + issueschema.ReframesDir, } if !slices.Equal(got, want) { t.Fatalf("LedgerDirs() = %v, want %v", got, want) @@ -38,7 +39,7 @@ func TestLedgerDirsNamesEveryConstant(t *testing.T) { // without a line above fails rather than passing silently. for _, sibling := range []string{ issueschema.ReadingsDir, issueschema.DispositionsDir, - issueschema.AdmissionsDir, issueschema.SurprisesDir, + issueschema.AdmissionsDir, issueschema.SurprisesDir, issueschema.ReframesDir, } { if !slices.Contains(got, sibling) { t.Errorf("LedgerDirs() omits the sibling family directory %q", sibling) diff --git a/internal/core/issueschema/reframe.go b/internal/core/issueschema/reframe.go new file mode 100644 index 000000000..d87610a1a --- /dev/null +++ b/internal/core/issueschema/reframe.go @@ -0,0 +1,119 @@ +package issueschema + +// The reframe record (itd-2609020625402518, spc-2609020626048705). +// +// A reframe occasioned by a reading is recorded as a reframe: one record naming +// what occasioned it, the content fingerprint of each of the frame's three +// committed surfaces before and after the rewrite, which of them changed, and +// the grounds. It carries NO text of any surface. adr-55 keeps the construal as +// it presently stands in the record and its history on the local side, and +// adr-2609021016288378 adds this record as a committed pointer to an event +// whose content stays there: the fingerprints identify which frame a reading +// saw and which replaced it, and nothing the record holds can reproduce the +// framing it abandoned. +// +// The record is written in two halves when it precedes the rewrite's commit: +// the before fingerprints and the grounds first, the after fingerprints and +// `changed` once the rewrite is committed. So the required set is the first +// half, and the allow-list is that set plus the after half. + +import ( + "regexp" + "strings" +) + +const ( + // ReframeFamily identifies one reframe record (rfm-N). It mints through + // recordid.Minter.Mint like every family in this workstream (adr-45). + ReframeFamily = "rfm" + // ReframesDir is FLAT, like SurprisesDir: reframes/rfm-.md. A reframe is + // keyed by what it carries — its occasion and its fingerprints — never by a + // directory, and like the other reading-chain families it is deliberately + // not in StatusDirs. + ReframesDir = "reframes" +) + +// ReframeRequired is every property a reframe record carries from its first +// write: the occasion, the three before fingerprints and the grounds. +// +// It is the ONE list: core/lint's rfm store and core/capture's writer both +// read it, so the gate and the writer cannot disagree about what a well-formed +// reframe carries. +var ReframeRequired = []string{ + "schema_version", "id", + "occasioned_by", + "construal_before", "glossary_before", "scope_before", + "grounds", +} + +// ReframeAfter is the half a reframe record gains when it is complete: the +// three after fingerprints and `changed`, the surfaces whose fingerprints +// differ. All four are absent while the record is open and present once it is +// complete, never some of them. +var ReframeAfter = []string{"construal_after", "glossary_after", "scope_after", "changed"} + +// ReframeKnown is the reframe record's allow-list: the required set plus the +// after half, and nothing else — no key that could hold a surface's text. +var ReframeKnown = func() map[string]bool { + known := knownSet(ReframeRequired) + for _, f := range ReframeAfter { + known[f] = true + } + return known +}() + +// ReframeOccasionFamilies is the CLOSED set of families a reframe's +// `occasioned_by` may name: a reading item, a disposition or a surprise. It is +// the one list the verb resolves the occasion over and the record gate holds a +// committed record to. +var ReframeOccasionFamilies = []string{ReadingItemFamily, DispositionFamily, SurpriseFamily} + +// FrameSurfaceNames are the three committed surfaces the frame is, in the +// order the record carries them (adr-55's enumeration, as adr-2609021016288378 +// adopted it): the framing chapter's construal section, the committed glossary +// terms and the committed scope. `changed` is drawn from this vocabulary, and +// each name N is carried as the pair N_before / N_after. +var FrameSurfaceNames = []string{"construal", "glossary", "scope"} + +// fingerprintRe is the shape every fingerprint takes: a SHA-256 in lower-case +// hex, and nothing else. +var fingerprintRe = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// ValidFingerprint reports whether v is verbatim a lower-case 64-hex SHA-256. +func ValidFingerprint(v string) bool { return fingerprintRe.MatchString(v) } + +// ValidFrameSurface reports whether v names one of the three frame surfaces. +func ValidFrameSurface(v string) bool { + for _, n := range FrameSurfaceNames { + if v == n { + return true + } + } + return false +} + +// ValidReframeOccasion reports whether v is VERBATIM a handle of one of +// ReframeOccasionFamilies: the family's own prefix, one hyphen and digits, with +// nothing around it. +func ValidReframeOccasion(v string) bool { return validHandleOf(ReframeOccasionFamilies, v) } + +// validHandleOf reports whether v is verbatim a handle of one of families. +func validHandleOf(families []string, v string) bool { + for _, f := range families { + rest, ok := strings.CutPrefix(v, f+"-") + if !ok || rest == "" { + continue + } + digits := true + for i := 0; i < len(rest); i++ { + if rest[i] < '0' || rest[i] > '9' { + digits = false + break + } + } + if digits { + return true + } + } + return false +} diff --git a/internal/core/issueschema/reframe_test.go b/internal/core/issueschema/reframe_test.go new file mode 100644 index 000000000..e268fd7e2 --- /dev/null +++ b/internal/core/issueschema/reframe_test.go @@ -0,0 +1,79 @@ +package issueschema_test + +import ( + "slices" + "testing" + + "github.com/intentdriven/abcd/internal/core/issueschema" +) + +// TestReframeKnownIsRequiredPlusAfter pins the reframe record's two lists +// (spc-2609020626048705): the required set is what a first half carries, and +// the allow-list is that set plus the after half, so an open record and a +// complete one are both well-formed and nothing else is. +func TestReframeKnownIsRequiredPlusAfter(t *testing.T) { + wantRequired := []string{"schema_version", "id", "occasioned_by", "construal_before", "glossary_before", "scope_before", "grounds"} + if !slices.Equal(issueschema.ReframeRequired, wantRequired) { + t.Fatalf("ReframeRequired = %v, want %v", issueschema.ReframeRequired, wantRequired) + } + after := []string{"construal_after", "glossary_after", "scope_after", "changed"} + if !slices.Equal(issueschema.ReframeAfter, after) { + t.Fatalf("ReframeAfter = %v, want %v", issueschema.ReframeAfter, after) + } + assertKnownCoversRequired(t, "ReframeKnown", issueschema.ReframeKnown, issueschema.ReframeRequired) + for _, f := range after { + if !issueschema.ReframeKnown[f] { + t.Errorf("ReframeKnown omits the after-half property %q", f) + } + } + if got, want := len(issueschema.ReframeKnown), len(wantRequired)+len(after); got != want { + t.Errorf("ReframeKnown holds %d keys, want exactly required plus after (%d)", got, want) + } + // The record carries no text of any surface: a key that could hold the + // abandoned framing is the one thing adr-55 forbids this record. + for k := range issueschema.ReframeKnown { + for _, banned := range []string{"text", "body", "prior", "construal"} { + if k == banned { + t.Errorf("ReframeKnown admits %q, a key that would carry a surface's text", k) + } + } + } + + if issueschema.ReframeFamily != "rfm" || issueschema.ReframesDir != "reframes" { + t.Errorf("family/store = %q/%q, want rfm/reframes", issueschema.ReframeFamily, issueschema.ReframesDir) + } + wantOcc := []string{issueschema.ReadingItemFamily, issueschema.DispositionFamily, issueschema.SurpriseFamily} + if !slices.Equal(issueschema.ReframeOccasionFamilies, wantOcc) { + t.Errorf("ReframeOccasionFamilies = %v, want %v", issueschema.ReframeOccasionFamilies, wantOcc) + } + if !slices.Equal(issueschema.FrameSurfaceNames, []string{"construal", "glossary", "scope"}) { + t.Errorf("FrameSurfaceNames = %v", issueschema.FrameSurfaceNames) + } +} + +// TestValidReframeOccasionIsVerbatimAndClosed holds the occasion to the three +// families by shape, with nothing around the handle. +func TestValidReframeOccasionIsVerbatimAndClosed(t *testing.T) { + for _, ok := range []string{"rdi-1", "dsp-22", "srp-2609251200001234"} { + if !issueschema.ValidReframeOccasion(ok) { + t.Errorf("ValidReframeOccasion(%q) = false, want true", ok) + } + } + for _, bad := range []string{"", "adm-1", "iss-1", "rdi-", "RDI-1", " rdi-1", "rdi-1 ", "rdi-1a", "a reading"} { + if issueschema.ValidReframeOccasion(bad) { + t.Errorf("ValidReframeOccasion(%q) = true, want false", bad) + } + } +} + +// TestLedgerDirectoriesCarryReframes: the reframe store is registered with the +// ledger's one directory list, which is what the comparative exclusion rows and +// the scribe's allow list derive from. +func TestLedgerDirectoriesCarryReframes(t *testing.T) { + if !slices.Contains(issueschema.LedgerDirs(), issueschema.ReframesDir) { + t.Fatalf("LedgerDirs() = %v omits %q", issueschema.LedgerDirs(), issueschema.ReframesDir) + } + if slices.Contains(issueschema.StatusDirs, issueschema.ReframesDir) { + t.Error("a reframe is not an issue; its store must stay out of StatusDirs") + } +} diff --git a/internal/core/reading/include.go b/internal/core/reading/include.go index 6fce015a8..e557264b9 100644 --- a/internal/core/reading/include.go +++ b/internal/core/reading/include.go @@ -68,7 +68,12 @@ import ( // assertExclusions where `unreachable path` was enforced by nothing, so the // floor gains a refusal a reader can check, which is MINOR by this constant's // own rule (iss-95). -const AssemblerVersionCore = "1.8.0" +// It goes 1.8.0 to 1.9.0 with the reframe record: Exclusions gains the row +// naming the family at every position, and the comparative position's derived +// ledger rows gain `.abcd/work/issues/reframes` from the ledger's directory +// list. Both are refusals a reader can now check, which is MINOR by this +// constant's own rule (spc-2609020626048705). +const AssemblerVersionCore = "1.9.0" // AssemblerVersion is the core semver with the rendered include table's digest // as semver build metadata. The digest is computed, not declared, so a table @@ -617,6 +622,12 @@ var Exclusions = []Exclusion{ {Rule: "no reading consumes the local ledger side, unconditionally and under no flag (brief invariant 14)", Signal: "directory", Detail: ".abcd/.work.local"}, {Rule: "absent from the positive walk", Signal: "record type in a denied path", Detail: "the lapse log"}, + // The reframe record (spc-2609020626048705): a pointer to a reframe whose + // content stays local, warm at every position. It lives under the ledger, so + // the container row above and the derived per-family row at comparative + // already refuse it by path; this row is the declaration a reader checks, + // naming the family rather than leaving it to be inferred from a directory. + {Rule: "absent from the positive walk", Signal: "record type in a denied path", Detail: "the reframe record"}, {Rule: "absent from the positive walk", Signal: "record type in a denied path", Detail: "admission and selection grounds"}, {Rule: "the instrument's own output is never its input", Signal: "directory", Detail: ".abcd/development/readings"}, {Rule: "the instrument's own output is never its input", Signal: "directory", Detail: "agents"}, diff --git a/internal/core/reading/include_test.go b/internal/core/reading/include_test.go index 3d64baeca..684548dc8 100644 --- a/internal/core/reading/include_test.go +++ b/internal/core/reading/include_test.go @@ -305,7 +305,7 @@ func TestBriefEvidenceChapterIsNeverAdmitted(t *testing.T) { // insufficient no longer matters: updating this literal without moving the core // can no longer make a manifest lie, because the manifest's digest is not this // literal. -const includeTableDigest = "3c58dd09fa1298856e4b35bba114f9d6ccbed0899a19893ec6bd80be0e0eaa39" +const includeTableDigest = "34a45d87635c8ffacc01d06d3df038d56f7ded9da8a93f0d681f2736c406bee0" // TestAssemblerVersionCoversTheIncludeTable puts the core semver in front of // whoever changed the table. It is ADVISORY by construction — the fix for a red diff --git a/internal/core/reading/reframe_test.go b/internal/core/reading/reframe_test.go new file mode 100644 index 000000000..d971907ba --- /dev/null +++ b/internal/core/reading/reframe_test.go @@ -0,0 +1,80 @@ +package reading + +import ( + "strings" + "testing" +) + +// The reframe record (spc-2609020626048705) is warm: it reaches no reading at +// any position, and every manifest asserts its exclusion. + +const sentinelReframe = "SENTINEL-REFRAME-RECORD" + +// ac-4. TestReframeRecordsNeverReachTheBundle plants a reframe record in the +// ledger and assembles at all four positions: the record's text never reaches +// the bundle, its path never reaches the manifest, and the manifest asserts +// the family's exclusion — by the container row at the three cold positions, +// by the derived per-family row at comparative, and by the floor's own +// reframe row at every one. +func TestReframeRecordsNeverReachTheBundle(t *testing.T) { + root := fixtureRepo(t) + writeFile(t, root, ".abcd/work/issues/reframes/rfm-1.md", + "---\nschema_version: 1\nid: rfm-1\noccasioned_by: rdi-1\ngrounds: \""+sentinelReframe+"\"\n---\n\n"+sentinelReframe+"\n") + gitCommitAll(t, root) + + for _, p := range AssemblingPositions() { + var res AssembleResult + if p == PositionComparative { + var err error + if res, err = assembleComparative(t, root); err != nil { + t.Fatalf("assemble at comparative: %v", err) + } + } else { + res = assembleFixture(t, root, p) + } + if strings.Contains(bundleText(res.Bundle), sentinelReframe) { + t.Errorf("position %s passed a reframe record into the bundle", p) + } + for _, path := range itemPaths(res.Manifest) { + if strings.HasPrefix(path, ".abcd/work/issues/reframes/") { + t.Errorf("position %s named %s in the manifest", p, path) + } + } + asserted := map[string]bool{} + for _, e := range res.Manifest.Exclusions { + asserted[e.Detail] = true + } + if !asserted["the reframe record"] { + t.Errorf("position %s: the manifest does not assert the reframe record's exclusion", p) + } + container := ".abcd/work/issues" + if p == PositionComparative { + container = ".abcd/work/issues/reframes" + } + if !asserted[container] { + t.Errorf("position %s: the manifest does not assert the exclusion of %s", p, container) + } + } +} + +// TestExclusionFloorNamesTheReframeRecord: the floor carries the reframe row +// at every position, beside the lapse log's, on the same rule and signal. +func TestExclusionFloorNamesTheReframeRecord(t *testing.T) { + for _, p := range Positions() { + found := false + for _, e := range ExclusionsFor(p) { + if e.Detail == "the reframe record" { + found = true + if e.Rule != "absent from the positive walk" || e.Signal != "record type in a denied path" { + t.Errorf("the reframe row reads %+v", e) + } + } + } + if !found { + t.Errorf("position %s: the exclusion floor does not name the reframe record", p) + } + } + if Admits(PositionComparative, ".abcd/work/issues/reframes/rfm-1.md") { + t.Error("the comparative position admits a reframe record") + } +} diff --git a/internal/core/readingitem/readingitem.go b/internal/core/readingitem/readingitem.go index a3c9157ad..6a060db65 100644 --- a/internal/core/readingitem/readingitem.go +++ b/internal/core/readingitem/readingitem.go @@ -43,6 +43,7 @@ const ( FamilyItem Family = issueschema.ReadingItemFamily // rdi-N, a reading item FamilyDisposition Family = issueschema.DispositionFamily // dsp-N, a disposition FamilyAdmission Family = issueschema.AdmissionFamily // adm-N, an admission + FamilySurprise Family = issueschema.SurpriseFamily // srp-N, a surprise FamilyIntent Family = "itd" // itd-N, a shipped intent ) @@ -187,10 +188,39 @@ func LocateAdmission(issuesRoot, id string) (run, path string, err error) { } } +// LocateSurprise finds the surprise record carrying id. The surprise store is +// FLAT (surprises/srp-N.md), so the walk is one leaf: the store is refused if it +// is a symlink, and the leaf is admitted only as a regular file by Lstat, so a +// symlinked record is never followed. Resolution is by presence in the store, +// so a surprise written by hand and one the surprise verb wrote resolve alike +// (spc-2609020626048705). +func LocateSurprise(issuesRoot, id string) (string, error) { + if !recordid.ValidSurpriseID(id) { + return "", fmt.Errorf("invalid %s-N identifier: %q", issueschema.SurpriseFamily, id) + } + root := filepath.Join(issuesRoot, issueschema.SurprisesDir) + if err := RefuseSymlinkedDir(root); err != nil { + return "", err + } + cand := filepath.Join(root, id+".md") + fi, err := os.Lstat(cand) + switch { + case err == nil && fi.Mode().IsRegular(): + return cand, nil + case err == nil: + return "", fmt.Errorf("%w: %s is not a regular file: %s", ErrPathUnsafe, id, cand) + case os.IsNotExist(err): + return "", fmt.Errorf("%w: %s is not a surprise this ledger holds", ErrUnknown, id) + default: + return "", err + } +} + // ResolveOccasion resolves id in one of the families the caller admits and // returns the path of the record it names. An id outside those families is // refused by shape before any path is built. A reading item, a disposition or an -// admission resolves through the ledger walk above, under repoRoot's issue ledger; an +// admission resolves through the ledger walk above, and a surprise through its +// flat store, under repoRoot's issue ledger; an // intent resolves only in repoRoot's intent store's shipped/ bucket, and a // record in any other bucket is refused naming the bucket. func ResolveOccasion(repoRoot, id string, families ...Family) (string, error) { @@ -217,6 +247,8 @@ func ResolveOccasion(repoRoot, id string, families ...Family) (string, error) { case FamilyAdmission: _, path, err := LocateAdmission(issuesRoot, id) return path, err + case FamilySurprise: + return LocateSurprise(issuesRoot, id) case FamilyIntent: return resolveShippedIntent(repoRoot, id) } diff --git a/internal/core/readingitem/readingitem_test.go b/internal/core/readingitem/readingitem_test.go index 55b888bb5..8071e4d3c 100644 --- a/internal/core/readingitem/readingitem_test.go +++ b/internal/core/readingitem/readingitem_test.go @@ -182,3 +182,42 @@ func TestLocateAdmissionFindsOneAcrossRuns(t *testing.T) { t.Errorf("a symlinked run bucket: err = %v, want ErrPathUnsafe", err) } } + +// TestResolveOccasionResolvesASurpriseAsARegularFileOnly is the surprise half +// of the occasion resolver (spc-2609020626048705): a reframe may be occasioned +// by a surprise, and a surprise is flat under surprises/, admitted only as a +// regular file. A symlinked leaf, a symlinked store and an absent record all +// refuse. +func TestResolveOccasionResolvesASurpriseAsARegularFileOnly(t *testing.T) { + root, ir := repo(t) + write(t, filepath.Join(ir, "surprises", "srp-11.md"), "a") + path, err := ResolveOccasion(root, "srp-11", FamilySurprise) + if err != nil || filepath.Base(path) != "srp-11.md" { + t.Fatalf("ResolveOccasion(srp-11) = %q %v", path, err) + } + if _, err := ResolveOccasion(root, "srp-12", FamilySurprise); !errors.Is(err, ErrUnknown) { + t.Errorf("an absent surprise: err = %v, want ErrUnknown", err) + } + outside := t.TempDir() + write(t, filepath.Join(outside, "srp-13.md"), "b") + if err := os.Symlink(filepath.Join(outside, "srp-13.md"), filepath.Join(ir, "surprises", "srp-13.md")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if _, err := ResolveOccasion(root, "srp-13", FamilySurprise); err == nil { + t.Error("a symlinked surprise leaf resolved; it must be refused") + } + // A surprise is not an occasion where the caller does not hand the family. + if _, err := ResolveOccasion(root, "srp-11", FamilyItem, FamilyDisposition); err == nil || !strings.Contains(err.Error(), "is not one of rdi-N, dsp-N") { + t.Errorf("a surprise without its family handed: err = %v", err) + } + // A symlinked store is refused before any leaf is looked at. + root2, ir2 := repo(t) + store := t.TempDir() + write(t, filepath.Join(store, "srp-14.md"), "c") + if err := os.Symlink(store, filepath.Join(ir2, "surprises")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if _, err := ResolveOccasion(root2, "srp-14", FamilySurprise); !errors.Is(err, ErrPathUnsafe) { + t.Errorf("a symlinked surprises store: err = %v, want ErrPathUnsafe", err) + } +} diff --git a/internal/core/recordid/valid.go b/internal/core/recordid/valid.go index 46449d28b..b723f5583 100644 --- a/internal/core/recordid/valid.go +++ b/internal/core/recordid/valid.go @@ -19,6 +19,7 @@ var ( readingItemIDRe = regexp.MustCompile(`^rdi-[0-9]+$`) admissionIDRe = regexp.MustCompile(`^adm-[0-9]+$`) surpriseIDRe = regexp.MustCompile(`^srp-[0-9]+$`) + reframeIDRe = regexp.MustCompile(`^rfm-[0-9]+$`) ) // ValidIntentID reports whether id is a well-formed intent id (itd-N). @@ -59,6 +60,11 @@ func ValidAdmissionID(id string) bool { return admissionIDRe.MatchString(id) } // `surprises/srp-N.md` and the dispatcher reads it back. func ValidSurpriseID(id string) bool { return surpriseIDRe.MatchString(id) } +// ValidReframeID reports whether id is a well-formed reframe id (rfm-N), on the +// same terms: the reframe verb writes `reframes/rfm-N.md`, completes it by id, +// and the dispatcher reads it back (spc-2609020626048705). +func ValidReframeID(id string) bool { return reframeIDRe.MatchString(id) } + // recordFilenameRe splits a record filename into its family prefix (group 1, // with its hyphen; empty for the ADR store's bare numeric form), its id number // (group 2), and its slug segment (group 3, empty when the name carries none). diff --git a/internal/core/recordid/valid_test.go b/internal/core/recordid/valid_test.go index 5afbfafc9..69631fce2 100644 --- a/internal/core/recordid/valid_test.go +++ b/internal/core/recordid/valid_test.go @@ -51,6 +51,11 @@ func TestAdmissionAndSurpriseIDGrammars(t *testing.T) { {"ValidSurpriseID", ValidSurpriseID, []string{"srp-1", "srp-2609251200001234", "srp-0007"}, []string{"", "null", "~", "srp-", "srp-1-slug", " srp-1", "srp-1\n", "SRP-1", "adm-1", "srp-../x", "dsp-1"}}, + // The reframe record (spc-2609020626048705) writes reframes/rfm-N.md and + // the dispatcher reads it back, so its grammar joins the two above. + {"ValidReframeID", ValidReframeID, + []string{"rfm-1", "rfm-2609251200001234", "rfm-0007"}, + []string{"", "null", "~", "rfm-", "rfm-1-slug", " rfm-1", "rfm-1\n", "RFM-1", "srp-1", "rfm-../x", "rfm-1/.."}}, } for _, c := range cases { for _, id := range c.ok { From 60c040e2b880416915579429f6081e880b86a7a7 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:59:38 +0100 Subject: [PATCH 13/78] feat(cli): capture reframe and abcd rfm-N as front doors onto the reframe record `abcd capture reframe --occasioned-by --grounds "" [--open]` and `abcd capture reframe --complete ` reach capture.Reframe; each render names the half it wrote (whole, first half with the completion to run, or completed across N commits), and --json carries the before and after triples, changed and commits. --complete with an occasion, a ground or --open is a usage error, as is neither. `abcd rfm-N` describes a reframe: its occasion and where it resolves, the before fingerprints and, once complete, the after fingerprints and the surfaces that changed. An open record reads `open` with its completion as the one next move, through verbCaptureReframe in RecommendedVerbPaths; a complete one reads `complete` with none. commands/capture.md gains "Record a reframe" and the argument-hint; commands/abcd.md and the abcd and capture brief chapters describe the dispatch and the verb; the surface snapshot, the CLI reference and the capture chapter's appendix are regenerated. Part of spc-2609020626048705. Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 50 +++++++++- .../development/brief/04-surfaces/08-abcd.md | 9 +- .abcd/development/release/surface.json | 34 +++++++ commands/abcd.md | 7 +- commands/capture.md | 64 ++++++++++++- docs/reference/cli/commands.md | 15 +++ internal/core/record/record.go | 92 +++++++++++++++++-- internal/core/record/record_test.go | 67 +++++++++++++- internal/surface/cli/capture_reframe_test.go | 75 +++++++++++++++ internal/surface/cli/capture_root_test.go | 14 +++ internal/surface/cli/cli.go | 52 +++++++++++ 11 files changed, 460 insertions(+), 19 deletions(-) create mode 100644 internal/surface/cli/capture_reframe_test.go diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index d672c665d..25eac0b94 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -32,6 +32,7 @@ binary. | `mentions` | — | shipped | | `migrate` | — | shipped | | `promote` | — | shipped | +| `reframe` | — | shipped | | `resolve` | — | shipped | | `surprise` | — | shipped | | `wontfix` | — | shipped | @@ -198,6 +199,26 @@ other family, and a handle naming nothing are refused before anything is minted. No disposition is written on this path. The record gate holds a hand-written surprise to the same closed form. +**Recording a reframe** writes one reframe record (`rfm-N`, under +`reframes/`) when a reading occasions a rewrite of the frame +(spc-2609020626048705). The frame is three committed surfaces at fixed paths: +the framing chapter's `Construal` section, the glossary terms (indexes and the +scaffold excepted) and the scope chapter. The record carries the occasion (a +reading item, a disposition or a surprise), the SHA-256 fingerprint of each +surface before and after, which surfaces changed, and the ground, and no text of +any surface. The verb reads the surfaces at `HEAD`, in the working tree and +along their history, so the operator supplies no hash. Written after the +rewrite's commit it is one write; written before it, a first half records the +before fingerprints and a second write finishes it once the rewrite is +committed, walking back across as many commits as the rewrite took, merges +included. Every render names the half it wrote. The occasion +is checked in one respect: the commit that added it precedes the rewrite. +Refused with nothing written: an occasion outside the three families or naming +no record, one not committed or committed after the rewrite, a degenerate +ground, uncommitted surface changes outside a first half, a frame with no distinct +prior state, a second open record, a completion in which nothing moved, and a +before state the history no longer holds within 64 commits touching the frame. + **Resolving** marks an issue resolved and moves it to `resolved/`. Impact is required, and resolving without it is refused with nothing written; grounds are recorded when given, their absence parked by @@ -404,6 +425,13 @@ for ad-hoc scribbles. - **Given** a surprise whose occasion resolves to a reading item, an admission or a disposition, **when** the user records it, **then** one surprise record exists as its own file and no disposition was touched. +- **Given** a reading item and any of the three frame surfaces rewritten and + committed, **when** the user records a reframe with the item as occasion and a + ground, **then** one reframe record exists carrying the occasion, the before + fingerprint of each surface's previously committed state, the after + fingerprint of each surface's current state, which surfaces changed and the + ground; a frame with no distinct prior state, or a before state the history + no longer holds, is refused naming the mismatch. - **Given** a run of widening items, **when** the bare board or `abcd lint` runs, **then** it counts the run's admitted, declined and held proposals and names each one carrying neither an admission nor a `declined` or `held` @@ -454,6 +482,15 @@ or naming no record, and either family filed in the other's store are each a blocker. `abcd ` and `abcd ` describe the record and its joins; the reading families `rdi`, `dsp` and `rdg` have no record dispatch. +The reframe record (itd-2609020625402518, spc-2609020626048705) has its schema +beside them in `internal/core/issueschema`, wired to `record_schema`, and its +writer, the three surface readers and the fingerprints in +`internal/core/capture/reframe.go`. The gate refuses a hand-written reframe with +a blank ground, a missing or mis-shaped fingerprint, a partial after half, a +`changed` outside the three surface names, or an occasion outside the closed +form. `abcd ` describes it. The family is warm: the cold-reading +assembler's exclusion floor names it at every position. + ## Appendix: the shipped surface @@ -462,7 +499,7 @@ _Generated from the command tree; a drift test fails `go test` when this appendi ### `abcd capture` -Sub-verbs: `abcd capture admit`, `abcd capture defer`, `abcd capture disposition`, `abcd capture link`, `abcd capture list`, `abcd capture mentions`, `abcd capture migrate`, `abcd capture promote`, `abcd capture resolve`, `abcd capture surprise`, `abcd capture wontfix`. +Sub-verbs: `abcd capture admit`, `abcd capture defer`, `abcd capture disposition`, `abcd capture link`, `abcd capture list`, `abcd capture mentions`, `abcd capture migrate`, `abcd capture promote`, `abcd capture reframe`, `abcd capture resolve`, `abcd capture surprise`, `abcd capture wontfix`. | Flag | Type | |---|---| @@ -553,6 +590,17 @@ Sub-verbs: none. | `--intent` | string | | `--production-mode` | string | +### `abcd capture reframe` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--complete` | string | +| `--grounds` | string | +| `--occasioned-by` | string | +| `--open` | bool | + ### `abcd capture resolve` Sub-verbs: none. diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index 4439c6c08..59199d315 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -45,10 +45,11 @@ form. record is, where it lives, and the concrete next move for its lifecycle state. An admission and a surprise are the issue ledger's two folderless families (spc-2609020626040342): their status reads `admitted` and `recorded`, their links -are the records they join to, and neither has a next move. `rfm-N` is admitted by -the gate for the reframe record, whose description lands with it -(spc-2609020626048705); until then it is refused naming that. The reading -families have no record dispatch. Bare answers *what can I +are the records they join to, and neither has a next move. A reframe record +(`rfm-N`, spc-2609020626048705) reads `open` until its after half is written and +`complete` after; its links are its occasion, the before fingerprints and, once +complete, the after fingerprints and the surfaces that changed, and an open one's +next move is its completion. The reading families have no record dispatch. Bare answers *what can I do*; the id form answers *what is this, and what is my next move* (spc-26, itd-121). A positional on the namespace root is not a `show` sub-verb, so the form stays inside the naming discipline. For an issue id it also names the diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index a6e7fcc6b..13e3f2eff 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -498,6 +498,40 @@ } ] }, + { + "path": "abcd capture reframe", + "hidden": false, + "flags": [ + { + "name": "complete", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "grounds", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "occasioned-by", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "open", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + } + ] + }, { "path": "abcd capture resolve", "hidden": false, diff --git a/commands/abcd.md b/commands/abcd.md index b06669ff1..a9b5bbab1 100644 --- a/commands/abcd.md +++ b/commands/abcd.md @@ -75,8 +75,11 @@ open issue points at `capture promote` / `resolve` / `wontfix`; decisions are read). An admission (`adm-N`) and a surprise (`srp-N`) have no folder, so their `status` is `admitted` or `recorded`; an admission's `links` are its `run`, `proposal`, `proposal_path` and the standing `disposition`, a surprise's are -`occasioned_by` and `occasion_path`, and neither carries a next move. An `rfm-N` -is refused naming the reframe record that has not landed. The reading families +`occasioned_by` and `occasion_path`, and neither carries a next move. A reframe +(`rfm-N`) reads `open` or `complete`; its `links` are `occasioned_by`, +`occasion_path`, the three `*_before` fingerprints and, once complete, the three +`*_after` fingerprints and `changed`, and an open one's next move is +`capture reframe --complete `. The reading families (`rdi-N`, `dsp-N`, `rdg-N`) are not dispatched. For an issue id the JSON also carries `ledger` — the `checkout` and `branch` whose ledger was read — because the same id can sit in another worktree's ledger in another state; name it when you report. A shape-matching id diff --git a/commands/capture.md b/commands/capture.md index b7c90cbc4..3e5c29c0a 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -1,7 +1,7 @@ --- name: capture -description: Capture issues to the structured per-repo ledger and query them, by invoking the abcd binary. Bare invocation is a read-only status render; admit/defer/disposition/link/list/promote/resolve/surprise/wontfix act on the ledger, and migrate rewrites retired back-links. -argument-hint: "[text] | list --open|--resolved|--wontfix|--all | link [--blocked-by ] [--unblock ] | promote --grounds \": \" [--intent ] | promote [--intent ] | resolve --impact --grounds \": \" [--intent ] [--spec ] [--commit ] | wontfix | defer --after --reason | disposition --state | admit --grounds \"\" | surprise --occasioned-by \"\" | migrate [--apply]" +description: Capture issues to the structured per-repo ledger and query them, by invoking the abcd binary. Bare invocation is a read-only status render; admit/defer/disposition/link/list/promote/reframe/resolve/surprise/wontfix act on the ledger, and migrate rewrites retired back-links. +argument-hint: "[text] | list --open|--resolved|--wontfix|--all | link [--blocked-by ] [--unblock ] | promote --grounds \": \" [--intent ] | promote [--intent ] | resolve --impact --grounds \": \" [--intent ] [--spec ] [--commit ] | wontfix | defer --after --reason | disposition --state | admit --grounds \"\" | surprise --occasioned-by \"\" | reframe --occasioned-by --grounds \"\" [--open] | reframe --complete | migrate [--apply]" --- # `/abcd:capture` — issue ledger @@ -500,6 +500,66 @@ were admitted, declined and held, and which carry neither an admission nor a widening proposal carrying neither an admission nor a decline is also reported on its own line, at `info`. +## Record a reframe + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" capture reframe --occasioned-by --grounds "" --json +"${CLAUDE_PLUGIN_ROOT}/abcd" capture reframe --occasioned-by --grounds "" --open --json +"${CLAUDE_PLUGIN_ROOT}/abcd" capture reframe --complete --json +``` + +When a reading sends the researcher back to the frame rather than to the +artefact, the rewrite is recorded as a **reframe record** (`rfm-N`, under +`.abcd/work/issues/reframes/`). The frame is three committed surfaces at fixed +paths: the `## Construal` section of +`.abcd/development/brief/01-product/06-framing.md`, the glossary terms under +`.abcd/development/brief/glossary/` (each `README.md` index and the +`_template.md` scaffold excepted), and the scope chapter +`.abcd/development/brief/01-product/04-scope.md`. The record carries the +occasion, the SHA-256 fingerprint of each surface before and after the rewrite, +`changed` (the surfaces that moved) and the ground. The prior text of any +surface never enters it: the framing a rewrite abandons stays on the local side +(adr-55). The verb reads the surfaces itself, so no hash is supplied. + +A reframe is written in one of three halves, and every render names which: + +- **Whole**, after the rewrite is committed (no flag). All three surfaces in the + working tree must match `HEAD`, or the verb refuses naming the one that does + not. It walks the surfaces' history to the previous distinct committed state + and writes both halves at once. +- **Open**, before the rewrite is committed (`--open`). The before fingerprints + are `HEAD`'s and the after half is absent; the render names the completion. + Only one record may be open at a time. +- **Completed** (`--complete rfm-N`), once the rewrite is committed. The verb + walks back from `HEAD` to the state the record opened against and writes the + after half. A rewrite spread over several commits, or brought in by a merge, + pairs the same way, and `commits` says how many commits it crossed. + +The occasion is a reading item, a disposition or a surprise this ledger holds, +and nothing else. The join is the operator's assertion, checked in one respect +only: the commit that added the occasion's record precedes the rewrite, or, for +`--open`, is already in `HEAD`'s history. That check is a floor, not a proof +that the occasion caused the rewrite. Report the `id`, `half`, `changed`, +`commits` and `path` from the JSON, and `redacted` whenever it is non-zero. + +Everything the verb refuses writes nothing: an occasion outside the three +families or naming no record, an occasion not yet committed or committed after +the rewrite, a ground below the floor, uncommitted changes to a surface without +`--open`, a frame with no distinct prior committed state, a second open record, +a completion in which no surface moved, and a completion whose before state the +surfaces' history no longer holds within 64 commits touching the frame (both +states named). The family is warm: no cold reading receives it, and every +manifest asserts its exclusion. `/abcd ` describes the record: its +occasion, its fingerprints and the surfaces that moved, or, while it is open, +the completion as its next move. + +The committed-tree gate holds a hand-written reframe to the same shape: +`record_schema` refuses a blank ground, a missing before fingerprint, an unknown +key, a fingerprint that is not 64 lower-case hex, an after half present in part, +a `changed` naming anything but `construal`, `glossary` or `scope`, and an +occasion that is not an `rdi-N`, `dsp-N` or `srp-N` naming a record the corpus +holds. + ## Promote an issue into an intent When a one-line issue turns out to be a capability, graduate it without diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 78702ed77..fa69cb246 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -282,6 +282,21 @@ Graduate an issue or a dispositioned reading item into an intent draft (mints + --production-mode string how this record's text was produced: hand-written|dictated-and-formatted|scribe-transcribed (default: the repo's declared mode, else hand-written) ``` +#### `abcd capture reframe` + +Record a reframe a reading occasioned: the frame's fingerprints before and after, and which surfaces moved + +**Usage:** `abcd capture reframe --occasioned-by --grounds "" [--open] | --complete [flags]` + +**Flags:** + +``` + --complete string the open reframe record (rfm-N) to finish once the rewrite is committed + --grounds string why the frame moved (free text, held to the grounds floor) + --occasioned-by string the record that occasioned the reframe: a reading item (rdi-N), a disposition (dsp-N) or a surprise (srp-N) + --open record the first half before the rewrite is committed; complete it after with --complete +``` + #### `abcd capture resolve` Mark an open issue resolved (open/ -> resolved/), optionally naming what fixed it diff --git a/internal/core/record/record.go b/internal/core/record/record.go index 50d0ac2da..79f18b239 100644 --- a/internal/core/record/record.go +++ b/internal/core/record/record.go @@ -1,5 +1,5 @@ // Package record is the read side of `abcd `: dispatch on a record id — -// iss-N, itd-N, spc-N, adr-N, and the ledger's adm-N and srp-N — and report what the record is, its links, and +// iss-N, itd-N, spc-N, adr-N, and the ledger's adm-N, srp-N and rfm-N — and report what the record is, its links, and // the concrete next move for its lifecycle state (spc-26). It is a leaf // package over the capture, intent, and spec read paths plus a thin adr // reader; nothing imports it back, and nothing here writes or knows a @@ -30,8 +30,8 @@ import ( // here. Anything else stays on the unknown-command path, byte-for-byte. // // adm, srp and rfm joined in one edit (spc-2609020626040342): rfm because the -// reframe spec (spc-2609020626048705) lands after it and inherits the gate -// rather than making a second edit — its Describe case is that spec's. The +// reframe spec (spc-2609020626048705) landed after it and inherited the gate +// rather than making a second edit; describeReframe is that spec's. The // reading families rdi, dsp and rdg stay outside it, the residual spc-67 names. var IDRe = regexp.MustCompile(`^(iss|itd|spc|adr|adm|srp|rfm)-[0-9]+$`) @@ -57,9 +57,9 @@ var ErrSkippedRecord = errors.New("skipped on read") // superseded_by) as present. type Description struct { ID string `json:"id"` - Family string `json:"family"` // issue | intent | spec | adr | admission | surprise + Family string `json:"family"` // issue | intent | spec | adr | admission | surprise | reframe Title string `json:"title"` - Status string `json:"status"` // folder/bucket, directory-as-truth; admitted | recorded for the two folderless ledger families + Status string `json:"status"` // folder/bucket, directory-as-truth; admitted | recorded | open | complete for the folderless ledger families Path string `json:"path"` Links map[string]string `json:"links,omitempty"` NextMoves []string `json:"next_moves,omitempty"` @@ -77,6 +77,7 @@ const ( verbCapturePromote = "capture promote" verbCaptureResolve = "capture resolve" verbCaptureWontfix = "capture wontfix" + verbCaptureReframe = "capture reframe" // verbIntentLink is never written by this package directly: it reaches // NextMoves through intent.Ready's spec_link remedies, which the // planned-not-ready branch passes through verbatim. It is pinned here so @@ -90,7 +91,7 @@ const ( func RecommendedVerbPaths() []string { return []string{ verbIntentPlan, verbIntentReady, verbIntentLink, verbIntentUnhold, verbSpecClose, - verbCapturePromote, verbCaptureResolve, verbCaptureWontfix, + verbCapturePromote, verbCaptureResolve, verbCaptureWontfix, verbCaptureReframe, } } @@ -114,7 +115,7 @@ func Describe(repoRoot, id string) (Description, error) { case "srp": return describeSurprise(repoRoot, id) case "rfm": - return Description{}, fmt.Errorf("record: %s — the reframe family is dispatched by its own record's spec (spc-2609020626048705), which has not landed; no reframe store exists to read", id) + return describeReframe(repoRoot, id) default: return describeADR(repoRoot, id) } @@ -553,6 +554,83 @@ func describeSurprise(repoRoot, id string) (Description, error) { return d, nil } +// describeReframe renders a reframe record (spc-2609020626048705): its +// occasion and where that resolves, the three before fingerprints and, once +// complete, the three after fingerprints and the surfaces that moved. Its +// status is `open` while the after half is absent and `complete` once it is +// present; an open record's one next move is the completion. +func describeReframe(repoRoot, id string) (Description, error) { + if !recordid.ValidReframeID(id) { + return Description{}, fmt.Errorf("record: malformed rfm id %q", id) + } + issuesRoot := filepath.Join(repoRoot, filepath.FromSlash(capture.LedgerRelPath)) + dir := filepath.Join(issuesRoot, issueschema.ReframesDir) + if err := readingitem.RefuseSymlinkedDir(dir); err != nil { + return Description{}, fmt.Errorf("record: %s: %w", id, err) + } + path := filepath.Join(dir, id+".md") + if fi, err := os.Lstat(path); err != nil || !fi.Mode().IsRegular() { + return Description{}, fmt.Errorf("record: %s not found in %s/%s", id, capture.LedgerRelPath, issueschema.ReframesDir) + } + fields, _ := readRecordHead(path, "") + occ := headValue(fields, "occasioned_by", "") + d := Description{ + ID: id, + Family: "reframe", + Title: "reframe occasioned by " + occ, + Status: "open", + Path: filepath.ToSlash(relTo(repoRoot, path)), + Links: map[string]string{}, + } + if occ == "" { + d.Title = "reframe" + } else { + d.Links["occasioned_by"] = occ + if issueschema.ValidReframeOccasion(occ) { + fams := make([]readingitem.Family, 0, len(issueschema.ReframeOccasionFamilies)) + for _, f := range issueschema.ReframeOccasionFamilies { + fams = append(fams, readingitem.Family(f)) + } + if opath, err := readingitem.ResolveOccasion(repoRoot, occ, fams...); err == nil { + d.Links["occasion_path"] = filepath.ToSlash(relTo(repoRoot, opath)) + } + } + } + for _, n := range issueschema.FrameSurfaceNames { + if v := headValue(fields, n+"_before", ""); v != "" { + d.Links[n+"_before"] = v + } + } + if headValue(fields, "construal_after", "") != "" { + d.Status = "complete" + for _, n := range issueschema.FrameSurfaceNames { + if v := headValue(fields, n+"_after", ""); v != "" { + d.Links[n+"_after"] = v + } + } + if v := inlineList(headValue(fields, "changed", "")); v != "" { + d.Links["changed"] = v + } + return d, nil + } + d.NextMoves = []string{ + "commit the rewrite, then `abcd " + verbCaptureReframe + " --complete " + id + "`", + } + return d, nil +} + +// inlineList renders a one-line flow sequence (`["a", "b"]`) as `a, b`. +func inlineList(v string) string { + v = strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(v), "["), "]") + var out []string + for _, item := range strings.Split(v, ",") { + if item = strings.Trim(strings.TrimSpace(item), `"'`); item != "" { + out = append(out, item) + } + } + return strings.Join(out, ", ") +} + // headValue reads one frontmatter value, unquoted, or fallback when it is absent // or null. func headValue(fields map[string]frontmatter.Field, key, fallback string) string { diff --git a/internal/core/record/record_test.go b/internal/core/record/record_test.go index 8e32b59f8..37257d6dc 100644 --- a/internal/core/record/record_test.go +++ b/internal/core/record/record_test.go @@ -369,7 +369,7 @@ func TestRecommendedVerbPathsClosed(t *testing.T) { "intent plan": true, "intent ready": true, "intent link": true, "intent unhold": true, "spec close": true, "capture promote": true, "capture resolve": true, - "capture wontfix": true, + "capture wontfix": true, "capture reframe": true, } got := RecommendedVerbPaths() if len(got) != len(want) { @@ -747,8 +747,8 @@ func TestDescribeSkippedIssueMatchesTheRosterByNumber(t *testing.T) { } // The ledger families spc-2609020626040342 adds to the dispatcher: admissions -// and surprises. rfm is admitted by the gate here and described by the reframe -// spec that lands after; the reading families stay outside it. +// and surprises, with rfm admitted by the same edit and described by the reframe +// spec (spc-2609020626048705); the reading families stay outside it. func TestIDReAdmitsTheThreeNewFamilies(t *testing.T) { for _, id := range []string{"adm-1", "srp-2609251200001234", "rfm-3"} { if !IDRe.MatchString(id) { @@ -839,3 +839,64 @@ func TestDescribeSurprise(t *testing.T) { } assertZeroWrites(t, repo, before) } + +// ac-5 (spc-2609020626048705): `abcd rfm-N` reports the occasion, the +// fingerprints and which surfaces moved. An open record says so and names the +// verb that completes it; a complete one emits no next move. It writes nothing. +func TestDescribeReframeReportsOccasionAndFingerprints(t *testing.T) { + repo := admissionLedger(t) + const ( + a = "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" + b = "3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d" + c = "2e7d2c03a9507ae265ecf5b5356885a53393a2029d241394997265a1a25aefc6" + d = "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4" + ) + dir := filepath.Join(filepath.FromSlash(capture.LedgerRelPath), "reframes") + head := "---\nschema_version: 1\nid: rfm-51\noccasioned_by: rdi-11\nconstrual_before: " + a + + "\nglossary_before: " + b + "\nscope_before: " + c + "\ngrounds: the reading sent us back to the frame\n" + write(t, repo, filepath.Join(dir, "rfm-51.md"), + head+"construal_after: "+d+"\nglossary_after: "+b+"\nscope_after: "+c+"\nchanged: [\"construal\"]\n---\n\n") + write(t, repo, filepath.Join(dir, "rfm-52.md"), strings.Replace(head, "rfm-51", "rfm-52", 1)+"---\n\n") + before := treeSnapshot(t, repo) + + done, err := Describe(repo, "rfm-51") + if err != nil { + t.Fatalf("Describe(rfm-51): %v", err) + } + if done.Family != "reframe" || done.Status != "complete" || done.Title != "reframe occasioned by rdi-11" { + t.Fatalf("description = %+v", done) + } + want := map[string]string{ + "occasioned_by": "rdi-11", "occasion_path": filepath.ToSlash(filepath.Join(capture.LedgerRelPath, "readings", "rdg-7", "rdi-11.md")), + "construal_before": a, "glossary_before": b, "scope_before": c, + "construal_after": d, "glossary_after": b, "scope_after": c, "changed": "construal", + } + for k, v := range want { + if done.Links[k] != v { + t.Errorf("links[%s] = %q, want %q", k, done.Links[k], v) + } + } + if len(done.NextMoves) != 0 { + t.Errorf("a complete reframe emits no next move; got %v", done.NextMoves) + } + + open, err := Describe(repo, "rfm-52") + if err != nil { + t.Fatalf("Describe(rfm-52): %v", err) + } + if open.Status != "open" || open.Links["construal_before"] != a { + t.Fatalf("open description = %+v", open) + } + for _, k := range []string{"construal_after", "glossary_after", "scope_after", "changed"} { + if _, ok := open.Links[k]; ok { + t.Errorf("an open reframe links %s", k) + } + } + if len(open.NextMoves) != 1 || !strings.Contains(open.NextMoves[0], "`abcd capture reframe --complete rfm-52`") { + t.Errorf("open next moves = %v", open.NextMoves) + } + if _, err := Describe(repo, "rfm-99"); err == nil || !strings.Contains(err.Error(), "rfm-99") { + t.Errorf("an absent reframe must fault naming it; got %v", err) + } + assertZeroWrites(t, repo, before) +} diff --git a/internal/surface/cli/capture_reframe_test.go b/internal/surface/cli/capture_reframe_test.go new file mode 100644 index 000000000..6190d01ab --- /dev/null +++ b/internal/surface/cli/capture_reframe_test.go @@ -0,0 +1,75 @@ +package cli + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/gittest" +) + +// reframeCLIRepo lays out a checkout carrying the three frame surfaces and one +// committed reading item, and changes into it. +func reframeCLIRepo(t *testing.T) *gittest.Repo { + t.Helper() + r := gittest.NewRepo(t) + r.Write(".abcd/development/brief/01-product/06-framing.md", "# Framing\n\n## Construal\n\nThe first construal.\n") + r.Write(".abcd/development/brief/01-product/04-scope.md", "# Scope\n\nThe first scope.\n") + r.Write(".abcd/development/brief/glossary/core/term.md", "# Term\n") + r.Write(".abcd/work/issues/readings/rdg-1/rdi-11.md", "---\nid: rdi-11\n---\n") + r.Commit("base") + t.Chdir(r.Root()) + return r +} + +// `capture reframe` is a front door (spc-2609020626048705): reachable from the +// CLI, and each of its renders names the half it wrote. +func TestCaptureReframeSurface(t *testing.T) { + r := reframeCLIRepo(t) + const ground = "the detection reading showed the construal was the wrong frame" + + out := runCLI(t, "capture", "reframe", "--occasioned-by", "rdi-11", "--grounds", ground, "--open") + if !strings.Contains(string(out), "first half written") || !strings.Contains(string(out), "capture reframe --complete rfm-") { + t.Fatalf("the open render does not name its half and the completion:\n%s", out) + } + id := strings.Fields(strings.TrimSpace(string(out[strings.Index(string(out), "rfm-"):])))[0] + + // --complete takes the record id alone. + if _, err := runCLIErr(t, "capture", "reframe", "--complete", id, "--occasioned-by", "rdi-11"); err == nil { + t.Fatal("--complete with --occasioned-by was accepted") + } + + r.Write(".abcd/development/brief/01-product/06-framing.md", "# Framing\n\n## Construal\n\nThe second construal.\n") + r.Git("add", ".abcd/development/brief") + r.Git("commit", "-q", "-m", "rewrite the construal") + out = runCLI(t, "capture", "reframe", "--complete", id) + if !strings.Contains(string(out), id+" completed across 1 commit(s)") || !strings.Contains(string(out), "construal") { + t.Fatalf("the completion render does not name its half:\n%s", out) + } + + r.Write(".abcd/development/brief/01-product/04-scope.md", "# Scope\n\nThe second scope.\n") + r.Git("add", ".abcd/development/brief") + r.Git("commit", "-q", "-m", "rewrite the scope") + out = runCLI(t, "capture", "reframe", "--occasioned-by", "rdi-11", "--grounds", ground, "--json") + var res struct { + ID string `json:"id"` + Half string `json:"half"` + Changed []string `json:"changed"` + Commits int `json:"commits"` + Before struct { + Scope string `json:"scope"` + } `json:"before"` + } + if err := json.Unmarshal(out, &res); err != nil { + t.Fatalf("reframe output not JSON: %v\n%s", err, out) + } + if res.Half != "whole" || len(res.Changed) != 1 || res.Changed[0] != "scope" || res.Commits != 1 || len(res.Before.Scope) != 64 { + t.Fatalf("whole write = %+v", res) + } + + // Neither flag set is a usage error naming what is missing. + if _, err := runCLIErr(t, "capture", "reframe", "--grounds", ground); err == nil || + !strings.Contains(err.Error(), "--occasioned-by") { + t.Fatalf("a reframe without an occasion: err = %v", err) + } +} diff --git a/internal/surface/cli/capture_root_test.go b/internal/surface/cli/capture_root_test.go index b991db174..4edf31167 100644 --- a/internal/surface/cli/capture_root_test.go +++ b/internal/surface/cli/capture_root_test.go @@ -305,6 +305,20 @@ func TestEveryCaptureVerbAddressesTheCheckoutLedger(t *testing.T) { } }, }, + // The reframe verb resolves its occasion in the checkout's ledger before + // it reads git: the seeded item is found there and refused only because no + // commit holds it yet, which a ledger resolved anywhere else could not say. + "reframe": { + args: func(_ []string, item string) []string { + return []string{"capture", "reframe", "--occasioned-by", item, "--grounds", + "the reading sent the researcher back to the frame", "--open", "--json"} + }, + check: func(t *testing.T, _ string, _ []string, item string, out []byte, err error) { + if err == nil || !strings.Contains(string(out)+err.Error(), item+" is not committed") { + t.Fatalf("capture reframe --occasioned-by %s from the subdirectory did not resolve the checkout's item: %v\n%s", item, err, out) + } + }, + }, "surprise": { args: func(_ []string, item string) []string { return []string{"capture", "surprise", "--occasioned-by", item, "the tension ran the other way from the one expected", "--json"} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index af0192615..94157463b 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -4078,6 +4078,58 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { surpriseCmd.Flags().StringVar(&surpriseOccasion, "occasioned-by", "", "the record that occasioned it: a reading item (rdi-N), an admission (adm-N) or a disposition (dsp-N)") captureCmd.AddCommand(surpriseCmd) + // reframe — one reframe occasioned by a reading, recorded as a reframe + // (spc-2609020626048705): the occasion, the fingerprints of the frame's + // three committed surfaces before and after, which moved, and the ground. + // `--open` writes the before half ahead of the rewrite's commit and + // `--complete rfm-N` finishes it after; every render names the half it wrote. + var reframeOccasion, reframeGrounds, reframeComplete string + var reframeOpen bool + reframeCmd := &cobra.Command{ + Use: "reframe --occasioned-by --grounds \"\" [--open] | --complete ", + Short: "Record a reframe a reading occasioned: the frame's fingerprints before and after, and which surfaces moved", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if reframeComplete == "" && strings.TrimSpace(reframeOccasion) == "" { + return &exitError{Code: 2, Msg: "abcd capture reframe: --occasioned-by is required, or --complete to finish an open record (nothing written)"} + } + if reframeComplete != "" && (reframeOccasion != "" || reframeGrounds != "" || reframeOpen) { + return &exitError{Code: 2, Msg: "abcd capture reframe: --complete takes the record id alone; the occasion and the ground are the first half's (nothing written)"} + } + repoRoot, err := captureLedgerRoot(cmd) + if err != nil { + return err + } + res, err := capture.Reframe(capture.ReframeRequest{ + RepoRoot: repoRoot, OccasionedBy: reframeOccasion, Grounds: reframeGrounds, + Open: reframeOpen, Complete: reframeComplete, + }) + if err != nil { + return err + } + return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { + switch res.Half { + case capture.ReframeHalfOpen: + fmt.Fprintf(w, "%s first half written; commit the rewrite, then `abcd capture reframe --complete %s` — %s\n", + res.ID, res.ID, termsafe.Sanitize(res.Path)) + case capture.ReframeHalfCompleted: + fmt.Fprintf(w, "%s completed across %d commit(s): %s moved — %s\n", + res.ID, res.Commits, strings.Join(res.Changed, ", "), termsafe.Sanitize(res.Path)) + default: + fmt.Fprintf(w, "%s reframe written whole across %d commit(s): %s moved — %s\n", + res.ID, res.Commits, strings.Join(res.Changed, ", "), termsafe.Sanitize(res.Path)) + } + fmt.Fprintf(w, " occasioned by %s\n", res.OccasionedBy) + emitRedactionNote(w, res.Redacted, res.Degraded) + }) + }, + } + reframeCmd.Flags().StringVar(&reframeOccasion, "occasioned-by", "", "the record that occasioned the reframe: a reading item (rdi-N), a disposition (dsp-N) or a surprise (srp-N)") + reframeCmd.Flags().StringVar(&reframeGrounds, "grounds", "", "why the frame moved (free text, held to the grounds floor)") + reframeCmd.Flags().BoolVar(&reframeOpen, "open", false, "record the first half before the rewrite is committed; complete it after with --complete") + reframeCmd.Flags().StringVar(&reframeComplete, "complete", "", "the open reframe record (rfm-N) to finish once the rewrite is committed") + captureCmd.AddCommand(reframeCmd) + // wontfix — open -> wontfix with a reason. It needs no required --grounds: // the reason is already mandatory, so a wontfix could never be recorded // without grounds — what it lacked was the TYPE, which it stamps as From ee19ca4b0f349084cae8a12d2c621bc14ac26ccc Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:59:41 +0100 Subject: [PATCH 14/78] feat(lint): record_schema judges a reframe record The rfm store joins record_schema and prose_citation_resolves: its required and known sets are issueschema's one declaration, its occasioned_by is a closed join over rdi-N, dsp-N and srp-N that must resolve, and checkReframeRecordShape holds each fingerprint to 64 lower-case hex, the after half (three after fingerprints and changed) to all or nothing, and changed to a non-empty list of construal, glossary and scope. The duplicate-key reader table gains the store's two readers, which disagree: the completing verb refuses the file and the dispatcher keeps the first value. Part of spc-2609020626048705. Assisted-by: Claude:claude-opus-5-5 --- .abcd/record-lint.json | 6 +- .../core/lint/duplicatekeyreaders_test.go | 46 +++++++ internal/core/lint/schema.go | 91 ++++++++++++++ internal/core/lint/schema_reframe_test.go | 112 ++++++++++++++++++ 4 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 internal/core/lint/schema_reframe_test.go diff --git a/.abcd/record-lint.json b/.abcd/record-lint.json index dc31985fc..99c1ad66e 100644 --- a/.abcd/record-lint.json +++ b/.abcd/record-lint.json @@ -280,7 +280,8 @@ "dsp": ".abcd/work/issues/dispositions", "rdg": ".abcd/development/readings", "adm": ".abcd/work/issues/admissions", - "srp": ".abcd/work/issues/surprises" + "srp": ".abcd/work/issues/surprises", + "rfm": ".abcd/work/issues/reframes" } }, "prose_citation_resolves": { @@ -295,7 +296,8 @@ "dsp": ".abcd/work/issues/dispositions", "rdg": ".abcd/development/readings", "adm": ".abcd/work/issues/admissions", - "srp": ".abcd/work/issues/surprises" + "srp": ".abcd/work/issues/surprises", + "rfm": ".abcd/work/issues/reframes" } }, "record_provenance": { diff --git a/internal/core/lint/duplicatekeyreaders_test.go b/internal/core/lint/duplicatekeyreaders_test.go index 5dc968dda..1cd15fdd8 100644 --- a/internal/core/lint/duplicatekeyreaders_test.go +++ b/internal/core/lint/duplicatekeyreaders_test.go @@ -150,6 +150,19 @@ func duplicateKeyReaderRows() []readerRow { reader: "record.Describe → describeSurprise → readRecordHeadAndBody → frontmatter.Fields", want: keepsFirst, probe: probeSurprise, + }, { + // The reframe record's two readers disagree, which is why the store does + // not declare readerRefusesDuplicateKey: the verb that completes it + // refuses the file, and the dispatcher renders the first value. + store: "rfm", + reader: "capture.Reframe --complete → readReframeRecord → parseFrontmatterAndBody", + want: refuses, + probe: probeReframeComplete, + }, { + store: "rfm", + reader: "record.Describe → describeReframe → readRecordHead → frontmatter.Fields", + want: keepsFirst, + probe: probeReframeDescribe, }} } @@ -197,6 +210,7 @@ func TestThisRulesOwnScannerKeepsTheFirstValueInEveryStore(t *testing.T) { "work/issues/dispositions/rdi-2/dsp-3.md": "---\nschema_version: 1\nid: dsp-3\nid: dsp-404\nitem: rdi-2\nstate: accepted\n---\n\n", "work/issues/admissions/rdg-1/adm-3.md": "---\nschema_version: 1\nid: adm-3\nid: adm-404\nrun: rdg-1\nproposal: rdi-2\ngrounds: it widens the frame\n---\n\n", "work/issues/surprises/srp-6.md": "---\nschema_version: 1\nid: srp-6\nid: srp-404\noccasioned_by: rdi-2\n---\n\n", + "work/issues/reframes/rfm-6.md": dupReframe("id: rfm-6\nid: rfm-404"), } writeRel(t, root, "rec/.keep", "") for rel, body := range files { @@ -520,6 +534,37 @@ func probeSurprise(t *testing.T) answer { return which(t, d.Links["occasioned_by"], "FIRST-MARKER", "SECOND-MARKER") } +// dupReframe renders an open reframe record whose id line is idLines, so a +// probe can duplicate the key it asks about. +func dupReframe(idLines string) string { + const fp = "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" + return "---\nschema_version: 1\n" + idLines + "\noccasioned_by: rdi-2\nconstrual_before: " + fp + + "\nglossary_before: " + fp + "\nscope_before: " + fp + "\ngrounds: the reading sent us back to the frame\n---\n\n" +} + +// probeReframeComplete reads a reframe through the verb that completes it +// (spc-2609020626048705), which parses the record strictly before it touches git. +func probeReframeComplete(t *testing.T) answer { + root := t.TempDir() + writeRel(t, root, ".abcd/work/issues/reframes/rfm-6.md", + dupReframe("id: rfm-6\noccasioned_by: rdi-3")) + _, err := capture.Reframe(capture.ReframeRequest{RepoRoot: root, Complete: "rfm-6"}) + return errAnswer(t, err) +} + +// probeReframeDescribe reads the same shape through the record dispatcher, +// `abcd rfm-N`, which renders its occasion. +func probeReframeDescribe(t *testing.T) answer { + root := t.TempDir() + writeRel(t, root, ".abcd/work/issues/reframes/rfm-6.md", + strings.Replace(dupReframe("id: rfm-6\noccasioned_by: FIRST-MARKER"), "occasioned_by: rdi-2", "occasioned_by: SECOND-MARKER", 1)) + d, err := record.Describe(root, "rfm-6") + if err != nil { + return refuses + } + return which(t, d.Links["occasioned_by"], "FIRST-MARKER", "SECOND-MARKER") +} + func probeUnread(t *testing.T, rel, body string) answer { root, _ := readingLedger(t, detectionItem) writeRel(t, root, rel, body) @@ -691,6 +736,7 @@ func everyStoreConfig() lint.Config { "dsp": "work/issues/dispositions", "adm": "work/issues/admissions", "srp": "work/issues/surprises", + "rfm": "work/issues/reframes", }}, }, } diff --git a/internal/core/lint/schema.go b/internal/core/lint/schema.go index f94fa5009..84c2106f0 100644 --- a/internal/core/lint/schema.go +++ b/internal/core/lint/schema.go @@ -91,6 +91,9 @@ var ( // it carries rather than by a directory. admissionFileNumRe = recordid.FilenameNumRe(issueschema.AdmissionFamily) surpriseFileNumRe = recordid.FilenameNumRe(issueschema.SurpriseFamily) + // The reframe store is flat for the surprise store's reason: a reframe is + // keyed by the occasion and the fingerprints it carries (spc-2609020626048705). + reframeFileNumRe = recordid.FilenameNumRe(issueschema.ReframeFamily) // A YAML block-scalar header and nothing else: `|`, `>`, with the chomping and // indentation indicators the spelling allows (`|-`, `>+`, `|2-`). A key // carrying one holds its value on the lines BELOW it, so the same-line scanner @@ -423,6 +426,19 @@ var recordStores = []recordStore{ why: "a surprise is keyed to the record that occasioned it, and a join naming nothing joins nothing", oneOf: issueschema.SurpriseOccasionFamilies, }}}, + // The reframe record (spc-2609020626048705). Its schema comes from + // core/issueschema's one declaration, its occasion is a closed join over + // the families the verb resolves, and checkReframeRecordShape judges what + // the required-fields leg cannot: each fingerprint's shape, the after half + // together or not at all, and `changed` drawn from the three surface names. + {prefix: "rfm", noun: "reframe", nodeType: "reframe", + fileNumRe: reframeFileNumRe, fileFamily: "rfm", filename: "rfm-.md", + requiredFields: issueschema.ReframeRequired, knownFields: issueschema.ReframeKnown, + joins: []recordJoin{{ + field: "occasioned_by", + why: "a reframe is keyed to the reading record that occasioned it, and a join naming nothing joins nothing", + oneOf: issueschema.ReframeOccasionFamilies, + }}}, } // storeByPrefix returns the code-side store for a prefix. @@ -587,6 +603,7 @@ func checkRecordSchema(repoRoot string, cfg RuleConfig) ([]Finding, error) { out = append(out, checkRecordFilename(r, cfg.Severity, judged)...) out = append(out, checkRecordFilenameSlug(r, cfg.Severity, judged)...) out = append(out, checkIssueRecordShape(r, cfg.Severity, judged)...) + out = append(out, checkReframeRecordShape(r, cfg.Severity, judged)...) out = append(out, checkRecordRequiredFields(r, cfg.Severity, judged)...) out = append(out, checkRecordUnknownFields(r, cfg.Severity)...) out = append(out, checkRecordJoins(r, index, retired, cfg)...) @@ -1982,3 +1999,77 @@ func refsContain(refs []recordRef, want recordRef) bool { } return false } + +// checkReframeRecordShape judges a reframe record's values the way the reframe +// writer's validateReframeStrict does (spc-2609020626048705), so a record +// written by hand is refused where a written one would be: every fingerprint a +// 64-hex SHA-256, the after half — the three after fingerprints and `changed` — +// present together or not at all, and `changed` a non-empty list drawn from the +// three surface names. An ABSENT required value is the required-fields leg's +// business, so a blank is skipped here. +func checkReframeRecordShape(r schemaRecord, severity string, judged map[string]bool) []Finding { + if r.store.prefix != issueschema.ReframeFamily { + return nil + } + var out []Finding + add := func(field string, line int, msg string) { + if line == 0 { + line = 1 + } + mark(judged, field) + out = append(out, Finding{ + File: r.rel, Line: line, RuleID: ruleRecordSchema, Severity: severity, Message: msg, + }) + } + present := 0 + for _, n := range issueschema.FrameSurfaceNames { + for _, half := range []string{"_before", "_after"} { + f, ok := r.fields[n+half] + if !ok { + continue + } + if half == "_after" { + present++ + } + if isNull(strings.TrimSpace(f.value)) { + continue + } + if v := issueScalar(f.value); !issueschema.ValidFingerprint(v) { + add(n+half, f.line, n+half+" '"+v+"' is not a 64-hex SHA-256 fingerprint; the reframe writer "+ + "records each surface's content fingerprint in that shape and nothing else") + } + } + } + ch, hasChanged := r.fields["changed"] + if hasChanged { + present++ + } + if present != 0 && present != len(issueschema.FrameSurfaceNames)+1 { + line := ch.line + if !hasChanged { + line = 1 + } + add("changed", line, "a reframe's after half is the three after fingerprints and `changed` together, "+ + "or none of them while the record is open; this record carries part of it") + } + if hasChanged { + v := strings.TrimSpace(ch.value) + inner, isList := strings.CutPrefix(v, "[") + inner, closed := strings.CutSuffix(inner, "]") + switch { + case !isList || !closed: + add("changed", ch.line, "changed '"+v+"' is not an inline list of the surfaces that moved") + case strings.TrimSpace(inner) == "": + add("changed", ch.line, "changed is empty; a completed reframe in which no surface moved records no reframe") + default: + for _, item := range strings.Split(inner, ",") { + name := issueScalar(strings.TrimSpace(item)) + if !issueschema.ValidFrameSurface(name) { + add("changed", ch.line, "changed names '"+name+"', which is not one of the frame's surfaces ("+ + strings.Join(issueschema.FrameSurfaceNames, ", ")+")") + } + } + } + } + return out +} diff --git a/internal/core/lint/schema_reframe_test.go b/internal/core/lint/schema_reframe_test.go new file mode 100644 index 000000000..b688e6ffe --- /dev/null +++ b/internal/core/lint/schema_reframe_test.go @@ -0,0 +1,112 @@ +package lint + +import ( + "path/filepath" + "strings" + "testing" +) + +// The reframe record (spc-2609020626048705) is held by the committed-tree gate +// to the shape its writer produces, so a record written by hand is judged as a +// written one is. + +const ( + fpA = "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" + fpB = "3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d" + fpC = "2e7d2c03a9507ae265ecf5b5356885a53393a2029d241394997265a1a25aefc6" + fpD = "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4" +) + +func reframeSchemaConfig() Config { + stores := admissionStores() + stores["rfm"] = "work/issues/reframes" + return Config{ + Roots: []string{"rec"}, + Rules: map[string]RuleConfig{ + ruleRecordSchema: {Enabled: true, Severity: severityBlocker, RecordStores: stores}, + }, + } +} + +// reframeRecord renders a reframe record from its lines, one key per line. +func reframeRecord(lines ...string) string { + return "---\n" + strings.Join(lines, "\n") + "\n---\n\n" +} + +func reframeHead(id, occasion string) []string { + return []string{ + "schema_version: 1", "id: " + id, "occasioned_by: " + occasion, + "construal_before: " + fpA, "glossary_before: " + fpB, "scope_before: " + fpC, + "grounds: the detection reading sent the researcher back to the frame", + } +} + +func reframeAfter(changed string) []string { + return []string{"construal_after: " + fpD, "glossary_after: " + fpB, "scope_after: " + fpC, "changed: " + changed} +} + +func TestRecordSchemaJudgesAReframeRecord(t *testing.T) { + root := admissionCorpus(t) + dir := "work/issues/reframes/" + // Controls: an open record, and a complete one naming what moved. A rule + // watched only failing is a rule that might refuse everything. + writeFile(t, root, dir+"rfm-1.md", reframeRecord(reframeHead("rfm-1", "rdi-2")...)) + writeFile(t, root, dir+"rfm-2.md", reframeRecord(append(reframeHead("rfm-2", "rdi-2"), reframeAfter(`["construal"]`)...)...)) + + cases := map[string]struct { + lines []string + want string + }{ + "rfm-10.md": {replace(reframeHead("rfm-10", "rdi-2"), "grounds: ", "grounds:"), "'grounds'"}, + "rfm-11.md": {drop(reframeHead("rfm-11", "rdi-2"), "glossary_before"), "'glossary_before'"}, + "rfm-12.md": {append(reframeHead("rfm-12", "rdi-2"), "construal_text: the old frame"), "unknown frontmatter property 'construal_text'"}, + "rfm-13.md": {replace(reframeHead("rfm-13", "rdi-2"), "scope_before: ", "scope_before: sha256:beef"), "scope_before"}, + "rfm-14.md": {append(reframeHead("rfm-14", "rdi-2"), reframeAfter(`["construal", "frame"]`)...), "'frame'"}, + "rfm-15.md": {reframeHead("rfm-15", "rdi-9999"), "rdi-9999"}, + "rfm-16.md": {reframeHead("rfm-16", "adm-2"), "not a handle of"}, + "rfm-17.md": {append(reframeHead("rfm-17", "rdi-2"), "construal_after: "+fpD), "after half"}, + "rfm-18.md": {append(reframeHead("rfm-18", "rdi-2"), reframeAfter(`[]`)...), "changed"}, + } + for name, c := range cases { + writeFile(t, root, dir+name, reframeRecord(c.lines...)) + } + + fs, err := Lint(reframeSchemaConfig(), root) + if err != nil { + t.Fatal(err) + } + for name, c := range cases { + if !findingWith(fs, filepath.Join("work", "issues", "reframes", name), ruleRecordSchema, c.want) { + t.Errorf("%s: want a record_schema finding naming %q: %+v", name, c.want, fs) + } + } + for _, ok := range []string{"rfm-1.md", "rfm-2.md"} { + for _, f := range fs { + if f.RuleID == ruleRecordSchema && strings.HasSuffix(f.File, ok) { + t.Errorf("the well-formed control %s drew a finding: %+v", ok, f) + } + } + } +} + +// replace swaps the line beginning with prefix for with. +func replace(lines []string, prefix, with string) []string { + out := append([]string{}, lines...) + for i, l := range out { + if strings.HasPrefix(l, prefix) { + out[i] = with + } + } + return out +} + +// drop removes the line for key. +func drop(lines []string, key string) []string { + var out []string + for _, l := range lines { + if !strings.HasPrefix(l, key+":") { + out = append(out, l) + } + } + return out +} From f61c120c6606ea350b805012c761b0059193e865 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:12:08 +0100 Subject: [PATCH 15/78] test(evals): the reframe plant carries a body, and its comparative row is caught by path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watched on a scratch copy: an include row for reframes at the cold positions with the container row withdrawn leaks LEDGER-REFRAME, but only once the plant carries a body — the frontmatter alone reaches no item — so the plant gains a body line and its count moves to 2. At comparative an include row leaks nothing on this corpus (the preset selects the discipline kind and the candidate set only), so the reframes row is declared as caught by the family-absence oracle, which names the missing manifest assertion when ReframesDir leaves LedgerDirs — also watched. Part of spc-2609020626048705. Assisted-by: Claude:claude-opus-5-5 --- evals/coldreading_coverage_test.go | 14 ++++++++++---- evals/coldreading_fixture_test.go | 5 ++++- .../baseline/.abcd/work/issues/reframes/rfm-1.md | 2 ++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/evals/coldreading_coverage_test.go b/evals/coldreading_coverage_test.go index 34964f4aa..b7ba00062 100644 --- a/evals/coldreading_coverage_test.go +++ b/evals/coldreading_coverage_test.go @@ -700,10 +700,16 @@ var coverage = []coverageRow{ Classes: []string{"FATE"}, }, { - Rule: "reframe records never reach the comparative reading (spc-2609020626048705)", - Falsifier: "delete the derived reframes row and add an include row for it", - Caught: caughtLeak, - Classes: []string{"LEDGER-REFRAME"}, + // Caught by path, not by plant, and watched: with the derived row gone the + // comparative manifest stops asserting the family's exclusion, and the + // family-absence oracle's comparative row for reframes names it. An + // include row added at comparative leaks nothing on this corpus, because + // the comparative preset selects only the discipline kind and the + // candidate set, so the leak half of this rule has no plant that reaches + // it (spc-2609020626048705). + Rule: "reframe records never reach the comparative reading, and its manifest says so (spc-2609020626048705)", + Falsifier: "drop ReframesDir from issueschema.LedgerDirs, so the derived reframes row disappears", + Caught: caughtFamily, }, { Rule: "the status directories never reach the comparative reading", diff --git a/evals/coldreading_fixture_test.go b/evals/coldreading_fixture_test.go index cc65e9d9a..517cffd0d 100644 --- a/evals/coldreading_fixture_test.go +++ b/evals/coldreading_fixture_test.go @@ -306,8 +306,11 @@ var sentinelClasses = []sentinelClass{ // derived per-family row does at comparative — and its grounds are the // researcher's reasoning about the frame, which no reading may see. Name: "LEDGER-REFRAME", + // Twice in one record: in the grounds the writer records, and in a body + // only a hand-written record carries. The body is what an admitting row + // would pass, so it is the half that makes the exclusion falsifiable. Homes: []string{"repo:.abcd/work/issues/reframes/rfm-1.md"}, - Count: 1, + Count: 2, Why: "spc-2609020626048705: a reframe record is warm and reaches no reading; its " + "exclusion is asserted in every manifest, by the ledger rows and by the floor's " + "own reframe row", diff --git a/evals/testdata/cold-reading/baseline/.abcd/work/issues/reframes/rfm-1.md b/evals/testdata/cold-reading/baseline/.abcd/work/issues/reframes/rfm-1.md index e5fbe533e..cb77445e6 100644 --- a/evals/testdata/cold-reading/baseline/.abcd/work/issues/reframes/rfm-1.md +++ b/evals/testdata/cold-reading/baseline/.abcd/work/issues/reframes/rfm-1.md @@ -11,3 +11,5 @@ glossary_after: "3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009 scope_after: "2e7d2c03a9507ae265ecf5b5356885a53393a2029d241394997265a1a25aefc6" changed: ["construal"] --- + +ABCD-EVAL-SENTINEL-LEDGER-REFRAME: a note a researcher wrote under a hand-kept reframe record; the writer leaves the body empty, and a record written by hand may not. From 1a81070bf0991ac3fcb0660cc6893147659d5ff3 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:12:31 +0100 Subject: [PATCH 16/78] chore: capture the comparative coverage rows claiming a leak their mutation does not produce Refs: iss-2609251812216267 Assisted-by: Claude:claude-opus-5-5 --- ...k-coverage-matrix-claims-caughtleak-for-three.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609251812216267-the-read-block-coverage-matrix-claims-caughtleak-for-three.md diff --git a/.abcd/work/issues/open/iss-2609251812216267-the-read-block-coverage-matrix-claims-caughtleak-for-three.md b/.abcd/work/issues/open/iss-2609251812216267-the-read-block-coverage-matrix-claims-caughtleak-for-three.md new file mode 100644 index 000000000..27a91c240 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251812216267-the-read-block-coverage-matrix-claims-caughtleak-for-three.md @@ -0,0 +1,13 @@ +--- +schema_version: 1 +id: "iss-2609251812216267" +slug: "the-read-block-coverage-matrix-claims-caughtleak-for-three" +severity: "minor" +category: "bug" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +--- + +The read-block coverage matrix claims caughtLeak for three comparative rows — dispositions, admissions and surprises never reach the comparative reading, falsifier 'delete the derived row and add an include row for it' — but the mutation leaks nothing on the eval corpus: the comparative preset selects only the discipline kind and the candidate set, so an include row for the family at comparative emits no item. Watched on a scratch copy for surprises (an include row plus the derived row removed: TestReadBlockBaselineIsClean stays green). The removed derived row IS caught, by TestManifestNamesEveryExcludedFamily, so the rows' Caught should be caughtFamily with no class, or the corpus needs a comparative-reachable plant. Found while giving the reframe family its comparative coverage row (spc-2609020626048705), which is declared caughtFamily for this reason. From ba811321327ee84f2987374bb7552f6257e466fc Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:12:59 +0100 Subject: [PATCH 17/78] test(evals): the comparative derived-row coverage is caught by path, as watched The dispositions, admissions and surprises rows claimed a leak their falsifier does not produce: an include row at comparative emits no item on this corpus, because the comparative preset selects only the discipline kind and the candidate set. What removing a derived row does move is the manifest, and TestManifestNamesEveryExcludedFamily names the assertion that went missing (watched for dispositions and reframes on a scratch copy). The rows now say so: caughtFamily, falsifier the family dropped from issueschema.LedgerDirs, no class. Refs: iss-2609251812216267 Assisted-by: Claude:claude-opus-5-5 --- evals/coldreading_coverage_test.go | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/evals/coldreading_coverage_test.go b/evals/coldreading_coverage_test.go index b7ba00062..c754996fa 100644 --- a/evals/coldreading_coverage_test.go +++ b/evals/coldreading_coverage_test.go @@ -681,23 +681,26 @@ var coverage = []coverageRow{ Caught: caughtLeak, Classes: []string{"EXHAUST"}, }, - { - Rule: "dispositions never reach the comparative reading", - Falsifier: "delete the derived dispositions row and add an include row for it", - Caught: caughtLeak, - Classes: []string{"FATE"}, + // The four derived family rows below are caught by PATH: an include row for + // a family at comparative leaks nothing on this corpus, because the + // comparative preset selects only the discipline kind and the candidate set, + // so no plant can die there. What the derived row's removal does move is the + // manifest, and the family-absence oracle's comparative rows name the + // assertion that went missing (iss-2609251812216267). + { + Rule: "dispositions never reach the comparative reading, and its manifest says so", + Falsifier: "drop the dispositions directory from issueschema.LedgerDirs, so the derived row disappears", + Caught: caughtFamily, }, { - Rule: "admissions never reach the comparative reading", - Falsifier: "delete the derived admissions row and add an include row for it", - Caught: caughtLeak, - Classes: []string{"GROUNDS"}, + Rule: "admissions never reach the comparative reading, and its manifest says so", + Falsifier: "drop the admissions directory from issueschema.LedgerDirs, so the derived row disappears", + Caught: caughtFamily, }, { - Rule: "surprises never reach the comparative reading", - Falsifier: "delete the derived surprises row and add an include row for it", - Caught: caughtLeak, - Classes: []string{"FATE"}, + Rule: "surprises never reach the comparative reading, and its manifest says so", + Falsifier: "drop the surprises directory from issueschema.LedgerDirs, so the derived row disappears", + Caught: caughtFamily, }, { // Caught by path, not by plant, and watched: with the derived row gone the From 4de2d1081b5a2ac02c213c903c7827c45e74ce9d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:13:01 +0100 Subject: [PATCH 18/78] =?UTF-8?q?chore:=20resolve=20iss-2609251812216267?= =?UTF-8?q?=20=E2=80=94=20comparative=20coverage=20rows=20caught=20by=20pa?= =?UTF-8?q?th?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251812216267 Assisted-by: Claude:claude-opus-5-5 --- ...d-block-coverage-matrix-claims-caughtleak-for-three.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251812216267-the-read-block-coverage-matrix-claims-caughtleak-for-three.md (74%) diff --git a/.abcd/work/issues/open/iss-2609251812216267-the-read-block-coverage-matrix-claims-caughtleak-for-three.md b/.abcd/work/issues/resolved/iss-2609251812216267-the-read-block-coverage-matrix-claims-caughtleak-for-three.md similarity index 74% rename from .abcd/work/issues/open/iss-2609251812216267-the-read-block-coverage-matrix-claims-caughtleak-for-three.md rename to .abcd/work/issues/resolved/iss-2609251812216267-the-read-block-coverage-matrix-claims-caughtleak-for-three.md index 27a91c240..d483576fb 100644 --- a/.abcd/work/issues/open/iss-2609251812216267-the-read-block-coverage-matrix-claims-caughtleak-for-three.md +++ b/.abcd/work/issues/resolved/iss-2609251812216267-the-read-block-coverage-matrix-claims-caughtleak-for-three.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "the three comparative derived-row coverage rows now claim caughtFamily, the mechanism watched catching the mutation" +impact: internal +resolved_by: + commit: "ba811321327ee84f2987374bb7552f6257e466fc" --- The read-block coverage matrix claims caughtLeak for three comparative rows — dispositions, admissions and surprises never reach the comparative reading, falsifier 'delete the derived row and add an include row for it' — but the mutation leaks nothing on the eval corpus: the comparative preset selects only the discipline kind and the candidate set, so an include row for the family at comparative emits no item. Watched on a scratch copy for surprises (an include row plus the derived row removed: TestReadBlockBaselineIsClean stays green). The removed derived row IS caught, by TestManifestNamesEveryExcludedFamily, so the rows' Caught should be caughtFamily with no class, or the corpus needs a comparative-reachable plant. Found while giving the reframe family its comparative coverage row (spc-2609020626048705), which is declared caughtFamily for this reason. + +## Grounds + +- pursued: removing a derived family row is named by the family-absence oracle at comparative; a mutation that removed one and left the eval green would show it wrong From 6b9da2688c06030f8f65f52aec5b768fadeae118 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:13:11 +0100 Subject: [PATCH 19/78] chore: ship itd-2609020625402518 by closing spc-2609020626048705 The reframe record ships: the rfm-N family and its writer, capture reframe and abcd rfm-N, the record_schema store, and the exclusion floor's reframe row with the regenerated charter and the planted sentinel. The spec close moves the intent planned -> shipped with impact additive and repoints the links that named the old paths. The fidelity review is owed (receipt rcp-987317795e99). Delivers: itd-2609020625402518 Assisted-by: Claude:claude-opus-5-5 --- ...e-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md | 7 ++++--- ...e-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) rename .abcd/development/intents/{planned => shipped}/itd-2609020625402518-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md (90%) rename .abcd/development/specs/{open => closed}/spc-2609020626048705-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md (99%) diff --git a/.abcd/development/intents/planned/itd-2609020625402518-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md b/.abcd/development/intents/shipped/itd-2609020625402518-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md similarity index 90% rename from .abcd/development/intents/planned/itd-2609020625402518-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md rename to .abcd/development/intents/shipped/itd-2609020625402518-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md index 9416c85f0..d2903af26 100644 --- a/.abcd/development/intents/planned/itd-2609020625402518-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md +++ b/.abcd/development/intents/shipped/itd-2609020625402518-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md @@ -14,7 +14,7 @@ production_mode: dictated-and-formatted # A reframe occasioned by a reading is recorded as a reframe, joined to what occasioned it, without carrying the construal it replaced -Typed links: `builds_on` [itd-180](../shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) (record families in the issue tier), [itd-189](../shipped/itd-189-what-the-widening-reading-proposes-is-admitted-or-declined-o.md) (the surprise entry as its own act); `refines` [adr-55](../../decisions/adrs/0055-the-construal-stands-in-the-record-its-history-does-not.md) (a reframe record beside the construal, adopted as [adr-2609021016288378](../../decisions/adrs/2609021016288378-a-reframe-occasioned-by-a-reading-is-a-committed-pointer-to.md) in its three-surface form). +Typed links: `builds_on` [itd-180](itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) (record families in the issue tier), [itd-189](itd-189-what-the-widening-reading-proposes-is-admitted-or-declined-o.md) (the surprise entry as its own act); `refines` [adr-55](../../decisions/adrs/0055-the-construal-stands-in-the-record-its-history-does-not.md) (a reframe record beside the construal, adopted as [adr-2609021016288378](../../decisions/adrs/2609021016288378-a-reframe-occasioned-by-a-reading-is-a-committed-pointer-to.md) in its three-surface form). ## Press Release @@ -69,7 +69,7 @@ We expect a record keyed to the committed fingerprints of the three frame surfac ## Prior Art -- [adr-55](../../decisions/adrs/0055-the-construal-stands-in-the-record-its-history-does-not.md); the brief's framing chapter; [itd-180](../shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) and [itd-189](../shipped/itd-189-what-the-widening-reading-proposes-is-admitted-or-declined-o.md). +- [adr-55](../../decisions/adrs/0055-the-construal-stands-in-the-record-its-history-does-not.md); the brief's framing chapter; [itd-180](itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) and [itd-189](itd-189-what-the-widening-reading-proposes-is-admitted-or-declined-o.md). - The cold-reading rulings of 2026-08-28 in the decision log. ## Open Questions @@ -78,7 +78,8 @@ None. The flagged decisions are adopted as adr-2609021016288378; the family's id ## Audit Notes -_Empty. Populated by intent-auditor when intent moves to shipped/._ + +Fidelity review OWED (receipt rcp-987317795e99). ## Grounds diff --git a/.abcd/development/specs/open/spc-2609020626048705-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md b/.abcd/development/specs/closed/spc-2609020626048705-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md similarity index 99% rename from .abcd/development/specs/open/spc-2609020626048705-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md rename to .abcd/development/specs/closed/spc-2609020626048705-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md index 8412f3d04..4b5fb64ec 100644 --- a/.abcd/development/specs/open/spc-2609020626048705-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md +++ b/.abcd/development/specs/closed/spc-2609020626048705-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md @@ -10,7 +10,7 @@ production_mode: dictated-and-formatted ## Summary spc-2609020626048705 delivers -[itd-2609020625402518](../../intents/planned/itd-2609020625402518-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md). +[itd-2609020625402518](../../intents/shipped/itd-2609020625402518-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md). A new record family, `rfm-N`, lives flat under `.abcd/work/issues/reframes/` beside the surprise family, one record per reframe occasioned by a reading. It carries the occasion, the SHA-256 of each of the three frame surfaces as they From 85e5f3784d214dca449b3a66452c7d1758d510f1 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:13:24 +0100 Subject: [PATCH 20/78] style(evals): gofmt the reframe sentinel row Assisted-by: Claude:claude-opus-5-5 --- evals/coldreading_fixture_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evals/coldreading_fixture_test.go b/evals/coldreading_fixture_test.go index 517cffd0d..3ae3f2b74 100644 --- a/evals/coldreading_fixture_test.go +++ b/evals/coldreading_fixture_test.go @@ -305,7 +305,7 @@ var sentinelClasses = []sentinelClass{ // ledger's container row refuses it at the three cold positions, and the // derived per-family row does at comparative — and its grounds are the // researcher's reasoning about the frame, which no reading may see. - Name: "LEDGER-REFRAME", + Name: "LEDGER-REFRAME", // Twice in one record: in the grounds the writer records, and in a body // only a hand-written record carries. The body is what an admitting row // would pass, so it is the half that makes the exclusion falsifiable. From dd35b5cfa2211899815f7ae68ed612887f30bea5 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:42:11 +0100 Subject: [PATCH 21/78] chore: correct the Phase 9 decision's figures and capture three admission follow-ups Refs: iss-2609251842111403 Refs: iss-2609251842111593 Refs: iss-2609251842112266 Assisted-by: Claude:claude-opus-5-5 --- .abcd/work/DECISIONS.md | 1 + ...pens-the-admission-ordering-gate-through-the.md | 14 ++++++++++++++ ...-ordering-gate-reads-committed-as-a-run-json.md | 14 ++++++++++++++ ...-run-summary-s-stand-down-is-bypassed-for-an.md | 14 ++++++++++++++ 4 files changed, 43 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609251842111403-no-test-opens-the-admission-ordering-gate-through-the.md create mode 100644 .abcd/work/issues/open/iss-2609251842111593-the-admission-ordering-gate-reads-committed-as-a-run-json.md create mode 100644 .abcd/work/issues/open/iss-2609251842112266-the-widening-run-summary-s-stand-down-is-bypassed-for-an.md diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index f2d90feb3..497258ab0 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2539,3 +2539,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-25 — The load check's stray rule is "busy for its share" (ruling H1, the product thinker via the interview session, 07:57Z, on iss-2609231947544298). A long-running process outside abcd's lanes is a stray when it uses nearly all the CPU it could get on the machine as loaded: its lifetime CPU share is measured against its fair share, the online cores divided by the runnable demand, not against a fixed 0.9 of one core, so forty busy loops each at a fortieth of the machine all count. The share test applies to the caller's own processes and to other accounts' alike, and other accounts' strays stay counted only. It is not a second sample and not a summed-cores trigger. The build reads the runnable demand as the snapshot's one-minute load average and caps the fair share at one core, so on a machine loaded no higher than its cores the rule is the near-full core it was (`machineload.FairShare`, spc-2609232027132755). This closes the band between 1.125 and 4 times the cores in which the check said nothing (pinned by `TestStrayRuleSilentBand`, succeeded by `TestStrayRuleCoversTheOversubscribedBand`), and lets the load check's remainder spec close and itd-2609231434459890 ship. The check still warns and never refuses (the 2026-09-23 entry above on that intent). - 2026-09-25 — Autonomous run A defers every open capture routed to the product thinker out loud to v0.10.0, under the product thinker's directive of 2026-09-25 ("I want the ledger drained": a capture ends fixed, wontfix with its reason, closed as a duplicate, or deferred out loud where it needs a product-thinker ruling or a planning interview). The product thinker is away, so no one in the run can give those rulings. 190 records each carry `deferred_after: "v0.10.0"` and a `deferral_reason` that quotes the ruling owed verbatim. 184 of them renew a v0.9.0 grant that lapsed when v0.10.0 re-anchored, and 6 carried none. Every question is asked once in the run's rulings-owed list, grouped under the routing pass's eleven themes: A, planning interviews already ruled "plan next cycle" (27); B, confirmations owed on rulings already given (8); C, dependency and publish sign-offs (6); D, narrowing a shipped promise (4); E, principles and conventions to adopt (23); F, record schema and lint rules (34); G, security and trust design forks (16); H, autonomous runs, implement and multi-agent planning (22); I, site, docs voice and product story (17); J, future capabilities to plan or close (27); K, parked on a trigger, or a human act outside the tree (6). Within each theme the questions covering a major record come first, and the list is the agenda for the next interview. The same pass closes 9 duplicates and 44 captures on their recorded merits, so none of those is deferred (implementer of lane records1). - 2026-09-25 — itd-2609020625400194 (admission and surprise verbs) is delivered on main by autonomous run A's lane, built against the current main, and not by the parked `phase-9/admit` branch that delivered the same spec on 2026-09-04. Phase 9 stays parked on the product thinker's word of 2026-09-22 ("leave both parked workstreams"). Its build branch sits 213 commits behind main and ships six other intents that exist nowhere else, so the run neither merges nor edits it. The overlap is this one intent: when Phase 9 is unparked, its admission commits (the spec close in 30f42daa, with b0334b03, 5ca30f92 and 074dea98) are dropped or reconciled against this delivery. That reconciliation is owed to the product thinker and listed in the run's rulings-owed list. The ruling is reversible: nothing on the parked branches is touched (orchestrator abcd-39, autonomous run A). +- 2026-09-25 — Correction to the entry above on the parked Phase 9 branch: `phase-9/build` holds 213 commits that main does not and lacks 905 that main has (the "213 commits behind" figure counted the wrong direction). Of the six intents it ships that main lacks, four (itd-2609041232354002, itd-2609041232363172, itd-2609041232387746, itd-2609041232398582) also exist, as planned, on the parked `readings/opening-run` branch; itd-2609051548103731 and itd-2609051743587375 exist only on the Phase 9 branches. The ruling itself stands unchanged (orchestrator abcd-39, autonomous run A, on the admission review's finding 4). diff --git a/.abcd/work/issues/open/iss-2609251842111403-no-test-opens-the-admission-ordering-gate-through-the.md b/.abcd/work/issues/open/iss-2609251842111403-no-test-opens-the-admission-ordering-gate-through-the.md new file mode 100644 index 000000000..3960dae99 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251842111403-no-test-opens-the-admission-ordering-gate-through-the.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251842111403" +slug: "no-test-opens-the-admission-ordering-gate-through-the" +severity: "minor" +category: "tech-debt" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "evals/coldreading_rehearsal_test.go" +--- + +No test opens the admission ordering gate through the comparative channel's real writer: the rehearsal hand-plants the marker (evals/coldreading_rehearsal_test.go:1397), and the comparative eval ingests but never dispositions, so a writer that stops populating candidate_run would leave every such run un-answerable with every gate green (review-admission 3). diff --git a/.abcd/work/issues/open/iss-2609251842111593-the-admission-ordering-gate-reads-committed-as-a-run-json.md b/.abcd/work/issues/open/iss-2609251842111593-the-admission-ordering-gate-reads-committed-as-a-run-json.md new file mode 100644 index 000000000..85fa07d64 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251842111593-the-admission-ordering-gate-reads-committed-as-a-run-json.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251842111593" +slug: "the-admission-ordering-gate-reads-committed-as-a-run-json" +severity: "minor" +category: "security" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/capture/itemfate.go" +--- + +The admission ordering gate reads 'committed' as a run.json decoding to position comparative with a candidate_run, with no run_id/directory, manifest, item or git check (internal/core/capture/itemfate.go:145-186), so an untracked, id-less marker opens the gate; this matches the spec's Approach but not its line that no mutable file anywhere records the outcome (review-admission 2). diff --git a/.abcd/work/issues/open/iss-2609251842112266-the-widening-run-summary-s-stand-down-is-bypassed-for-an.md b/.abcd/work/issues/open/iss-2609251842112266-the-widening-run-summary-s-stand-down-is-bypassed-for-an.md new file mode 100644 index 000000000..83e7e29a1 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251842112266-the-widening-run-summary-s-stand-down-is-bypassed-for-an.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251842112266" +slug: "the-widening-run-summary-s-stand-down-is-bypassed-for-an" +severity: "minor" +category: "bug" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lint/readingoutstanding.go" +--- + +The widening-run summary's stand-down is bypassed for an admitted item: internal/core/lint/readingoutstanding.go:421-425 takes the admissions.admits case before the unsafe/contested/cyclic check, so a run whose only acceptance is unreadable or contested still reports 'admitted 1, outstanding []', contradicting the type's own doc comment (lines 131-135; review-admission 1). From 5428aa47828a2097b5a8267d65e40ab0b7ff6788 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:46:31 +0100 Subject: [PATCH 22/78] feat(reading): every bundle carries the per-run context stamp of its run core/sessionkind is a new leaf holding the one grammar for the per-run context stamp adr-2609021016275803 adopts: abcd.context-stamp///. Stamp refuses a kind outside reading and scribe, a malformed run and a short or non-hex digest; Parse matches exactly; Find returns the distinct stamps in a text between name boundaries, so a prefix in the docs is never one. The reading bundle gains context_stamp, set by Assemble to the reading stamp of the run it mints over the sha256 of its own item set. The bundle shape moves, so SchemaVersion goes 9 to 10 and AssemblerVersionCore 1.9.0 to 1.10.0 (MINOR, on itd-199's precedent). The stamp is the bundle's one run-dependent value, so every byte-identity check (the package's four, and the amnesia eval) now holds the stamps of two assemblies to one kind and one digest and compares the bytes with the run segment set aside. The pathless-bundle check parses the stamp as a token before judging the rest of the structure for separators. Part of itd-2609020625402599 / spc-2609020626045177. Assisted-by: Claude:claude-opus-5-5 --- evals/coldreading_determinism_test.go | 51 ++++++- internal/core/reading/assemble.go | 23 +++ internal/core/reading/assemble_test.go | 26 ++-- internal/core/reading/candidates_test.go | 10 +- internal/core/reading/include.go | 7 +- internal/core/reading/manifest.go | 32 +++-- internal/core/reading/manifest_test.go | 60 ++++++++ internal/core/reading/scope_test.go | 19 ++- internal/core/reading/size_test.go | 7 +- internal/core/reading/window_test.go | 2 +- internal/core/sessionkind/sessionkind.go | 132 ++++++++++++++++++ internal/core/sessionkind/sessionkind_test.go | 110 +++++++++++++++ 12 files changed, 443 insertions(+), 36 deletions(-) create mode 100644 internal/core/sessionkind/sessionkind.go create mode 100644 internal/core/sessionkind/sessionkind_test.go diff --git a/evals/coldreading_determinism_test.go b/evals/coldreading_determinism_test.go index ee39bb74e..6217a5098 100644 --- a/evals/coldreading_determinism_test.go +++ b/evals/coldreading_determinism_test.go @@ -9,7 +9,9 @@ package evals // agent can be trusted to follow, so it is checked here rather than exhibited in // a case run. The identity relation is byte-equality of the assembled input with // the manifest excluded, because the manifest legitimately carries a run -// identifier that differs between runs. The manifest is not therefore +// identifier that differs between runs, and with the bundle's per-run context +// stamp set aside after it is held to its run and to one digest across the pair +// (adr-2609021016275803). The manifest is not therefore // unasserted: it is held to two weaker properties — no timestamp-shaped key or // scalar (here), and item paths in lexicographic order // (coldreading_order_test.go). @@ -108,7 +110,17 @@ func TestAssembledInputIsByteIdenticalAcrossRuns(t *testing.T) { } }) - if diffs := compareArtefacts(bundleFile, a.BundleRaw, b.BundleRaw); len(diffs) > 0 { + // The bundle carries ONE run-dependent value, its per-run context stamp + // (adr-2609021016275803). Each side's stamp is held to its run and the + // two to one digest, and only then set aside for the byte comparison, + // so the relation stays byte-equality of everything else. + aRaw, aDigest := setStampAside(t, a) + bRaw, bDigest := setStampAside(t, b) + if aDigest != bDigest { + t.Fatalf("the two assemblies at %s stamp two digests (%s, %s) over what must be one "+ + "item set", position, aDigest, bDigest) + } + if diffs := compareArtefacts(bundleFile, aRaw, bRaw); len(diffs) > 0 { t.Fatalf("the assembled input at %s differs between two assemblies of ONE commit "+ "at two paths (%d difference(s)):\n%s\nthis is the assembler failing to be "+ "deterministic, not the eval being strict", position, len(diffs), reportDifferences(diffs)) @@ -396,6 +408,41 @@ func reportDifferences(ds []artefactDifference) string { return strings.Join(out, "\n") } +// readingStampRe is the reading kind's per-run context stamp, restated here +// rather than imported: this eval falsifies the assembler independently, so it +// reads the stamp by its own copy of the grammar and a drift between the two is +// a failure rather than a shared blind spot. +var readingStampRe = regexp.MustCompile(`^abcd\.context-stamp/reading/(rdg-[0-9]+)/([0-9a-f]{12})$`) + +// setStampAside checks one assembly's context stamp and returns the bundle with +// the stamp's run segment replaced by a fixed token, together with the digest. +// The stamp must name the run the manifest names: a stamp naming another run is +// a stamp a transcript would attribute to the wrong session. +func setStampAside(t *testing.T, a assembled) ([]byte, string) { + t.Helper() + var doc struct { + ContextStamp string `json:"context_stamp"` + } + if err := json.Unmarshal(a.BundleRaw, &doc); err != nil { + t.Fatalf("decoding the bundle at %s: %v", a.Position, err) + } + m := readingStampRe.FindStringSubmatch(doc.ContextStamp) + if m == nil { + t.Fatalf("the bundle at %s carries the context stamp %q, which is not a reading stamp", + a.Position, doc.ContextStamp) + } + if run := runIdentifier(t, a); m[1] != run { + t.Fatalf("the bundle at %s is stamped for run %s and its manifest names run %s", + a.Position, m[1], run) + } + if n := strings.Count(string(a.BundleRaw), doc.ContextStamp); n != 1 { + t.Fatalf("the bundle at %s carries its stamp %d times; it is one field", a.Position, n) + } + aside := strings.Replace(string(a.BundleRaw), doc.ContextStamp, + "abcd.context-stamp/reading//"+m[2], 1) + return []byte(aside), m[2] +} + // compareArtefacts is the comparison ac-1 rests on. The RELATION it asserts is // byte-equality of the whole artefact: equal bytes report nothing, and anything // else reports at least one difference. The decoded walk exists only to say diff --git a/internal/core/reading/assemble.go b/internal/core/reading/assemble.go index 454ae55d0..5038a2c9b 100644 --- a/internal/core/reading/assemble.go +++ b/internal/core/reading/assemble.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/lint" + "github.com/intentdriven/abcd/internal/core/sessionkind" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" ) @@ -589,6 +590,14 @@ func Assemble(req AssembleRequest) (AssembleResult, error) { manifest.Items = append(manifest.Items, mItem) } + // The stamp is set LAST over the bundle, because its digest is over the item + // set the loop above just finished (adr-2609021016275803). + stamp, err := bundleStamp(runID, bundle.Items) + if err != nil { + return AssembleResult{}, err + } + bundle.ContextStamp = stamp + hash, err := ManifestHash(manifest) if err != nil { return AssembleResult{}, err @@ -623,6 +632,20 @@ func Assemble(req AssembleRequest) (AssembleResult, error) { return res, notExercisedError(notExercised, candidateRun) } +// bundleStamp is the reading kind's per-run context stamp: the run and the +// sha256 over the bundle's item set as the canonical encoder serialises it. +func bundleStamp(runID string, items []BundleItem) (string, error) { + raw, err := encode(items) + if err != nil { + return "", err + } + stamp, err := sessionkind.Stamp(sessionkind.Reading, runID, sha256Hex(raw)) + if err != nil { + return "", fmt.Errorf("reading: stamping the bundle: %w", err) + } + return stamp, nil +} + // PositionNotExercised is the fixed interpretation as a refusal: the derived // widening run holds fewer than two candidates, so the comparative reading has // nothing to compare. diff --git a/internal/core/reading/assemble_test.go b/internal/core/reading/assemble_test.go index ea779ab19..51769c689 100644 --- a/internal/core/reading/assemble_test.go +++ b/internal/core/reading/assemble_test.go @@ -6,6 +6,8 @@ import ( "sort" "strings" "testing" + + "github.com/intentdriven/abcd/internal/core/sessionkind" ) // TestExcludedFieldsNeverReachTheBundle is itd-183's first criterion: given a @@ -202,6 +204,14 @@ func TestBundleCarriesNoRepositoryPath(t *testing.T) { res := assembleFixture(t, root, PositionWidening) skeleton := res.Bundle + // The context stamp carries separators and is not a location: it is a token + // sessionkind parses exactly, naming a kind, a run id and a digest. It is + // held to that grammar here and then set aside, so the rest of the structure + // is still judged for any separator at all. + if _, ok := sessionkind.Parse(skeleton.ContextStamp); !ok { + t.Fatalf("the bundle's context_stamp %q is not a stamp", skeleton.ContextStamp) + } + skeleton.ContextStamp = "" skeleton.Items = nil for _, it := range res.Bundle.Items { it.Text = "" @@ -233,6 +243,10 @@ func TestBundleCarriesNoRepositoryPath(t *testing.T) { t.Fatalf("assemble at comparative: %v", err) } cSkeleton := comparative.Bundle + if _, ok := sessionkind.Parse(cSkeleton.ContextStamp); !ok { + t.Fatalf("the comparative bundle's context_stamp %q is not a stamp", cSkeleton.ContextStamp) + } + cSkeleton.ContextStamp = "" cSkeleton.Items = nil candidates := 0 for _, it := range comparative.Bundle.Items { @@ -283,15 +297,9 @@ func TestWalkIsLexicographicAndByteStable(t *testing.T) { first := assembleFixture(t, root, PositionEntailment) second := assembleFixture(t, root, PositionEntailment) - a, err := EncodeBundle(first.Bundle) - if err != nil { - t.Fatalf("encode: %v", err) - } - b, err := EncodeBundle(second.Bundle) - if err != nil { - t.Fatalf("encode: %v", err) - } - if string(a) != string(b) { + // The two runs carry two run ids, so their stamps differ in the run segment + // and nowhere else (adr-2609021016275803). + if !identicalButForRun(t, first.Bundle, second.Bundle) { t.Error("two assemblies of one state produced different bundles") } diff --git a/internal/core/reading/candidates_test.go b/internal/core/reading/candidates_test.go index 73c532d70..0abaef58a 100644 --- a/internal/core/reading/candidates_test.go +++ b/internal/core/reading/candidates_test.go @@ -669,15 +669,7 @@ func TestTwoComparativeAssembliesAreByteIdentical(t *testing.T) { if err != nil { t.Fatalf("second assembly: %v", err) } - a, err := EncodeBundle(first.Bundle) - if err != nil { - t.Fatal(err) - } - b, err := EncodeBundle(second.Bundle) - if err != nil { - t.Fatal(err) - } - if string(a) != string(b) { + if !identicalButForRun(t, first.Bundle, second.Bundle) { t.Error("two comparative assemblies of one repository state produced different bundles") } } diff --git a/internal/core/reading/include.go b/internal/core/reading/include.go index e557264b9..341bcbc14 100644 --- a/internal/core/reading/include.go +++ b/internal/core/reading/include.go @@ -73,7 +73,12 @@ import ( // ledger rows gain `.abcd/work/issues/reframes` from the ledger's directory // list. Both are refusals a reader can now check, which is MINOR by this // constant's own rule (spc-2609020626048705). -const AssemblerVersionCore = "1.9.0" +// It goes 1.9.0 to 1.10.0 with the per-run context stamp: the bundle gains +// `context_stamp`, and the bundle shape is part of the contract a reader is +// PROMISED — it now carries a token naming its kind and its run — which is +// MINOR by this constant's own rule (adr-2609021016275803, +// spc-2609020626045177). +const AssemblerVersionCore = "1.10.0" // AssemblerVersion is the core semver with the rendered include table's digest // as semver build metadata. The digest is computed, not declared, so a table diff --git a/internal/core/reading/manifest.go b/internal/core/reading/manifest.go index 86101fbd2..b67b35cf5 100644 --- a/internal/core/reading/manifest.go +++ b/internal/core/reading/manifest.go @@ -40,8 +40,12 @@ import ( // derivation now admits a run whose own records were committed since it read, so // the run's target and the assembly's can differ and a reader needs both to // check the selection (iss-2609021833302981). The bundle is untouched and is -// restamped by the shared constant once more. -const SchemaVersion = 9 +// restamped by the shared constant once more. At version 10 the BUNDLE gains +// `context_stamp`, the per-run token naming the reading kind, the run and a +// digest of the item set, which a transcript retains and the separation check +// reads (adr-2609021016275803, spc-2609020626045177); the manifest is untouched +// and is restamped by the shared constant. +const SchemaVersion = 10 // The two artefact type tags. They are carried in the documents themselves so a // reader of a loose file can tell the two apart without its filename. @@ -74,14 +78,24 @@ type BundleItem struct { // Bundle is the assembled input: the reading's entire working set. // -// It carries no run identifier and no timestamp, so two assemblies of one -// repository state at one commit are byte-identical — the property itd-187's -// eval falsifies independently, and the reason the run identifier lives on the -// manifest alone. +// It carries no timestamp and exactly one run-dependent value, the context +// stamp, so two assemblies of one repository state at one commit are +// byte-identical but for the run segment of that stamp — the property itd-187's +// eval falsifies independently, with the stamp set aside and held to agree in +// kind and digest. type Bundle struct { - Type string `json:"_type"` - SchemaVersion int `json:"schema_version"` - Position Position `json:"position"` + Type string `json:"_type"` + SchemaVersion int `json:"schema_version"` + // ContextStamp is the per-run context stamp: the reading kind, the run this + // bundle was assembled for, and the first twelve hex digits of the sha256 + // over its own item set (adr-2609021016275803). A session that was handed + // this bundle through a tool the host retains carries the stamp in its + // transcript, and that is what the transcript store's separation check reads. + // + // It is a TOKEN and not a path, so brief invariant 15 holds: core/sessionkind + // parses it exactly, and it names no location and selects nothing. + ContextStamp string `json:"context_stamp"` + Position Position `json:"position"` // Preset is what THIS run was given, and it is the reading's own fact // rather than the auditor's. A reader told its object is the shipped tree // and handed a tenth of it will report the missing nine tenths as a diff --git a/internal/core/reading/manifest_test.go b/internal/core/reading/manifest_test.go index 6a0bd6545..f962eb02b 100644 --- a/internal/core/reading/manifest_test.go +++ b/internal/core/reading/manifest_test.go @@ -5,8 +5,68 @@ import ( "regexp" "strings" "testing" + + "github.com/intentdriven/abcd/internal/core/sessionkind" ) +// identicalButForRun reports whether two bundles of one repository state are +// the same bundle, which since the per-run context stamp means byte-identical +// with the stamp set aside AND stamped for one kind over one digest. The run +// segment is the one value a run id reaches, and it is the only thing allowed +// to differ (adr-2609021016275803). +func identicalButForRun(t *testing.T, a, b Bundle) bool { + t.Helper() + sa, okA := sessionkind.Parse(a.ContextStamp) + sb, okB := sessionkind.Parse(b.ContextStamp) + if !okA || !okB { + t.Fatalf("a bundle carries no stamp: %q, %q", a.ContextStamp, b.ContextStamp) + } + if sa.Kind != sb.Kind || sa.Digest != sb.Digest { + t.Errorf("two assemblies of one state are stamped %q and %q; only the run may differ", + a.ContextStamp, b.ContextStamp) + return false + } + a.ContextStamp, b.ContextStamp = "", "" + return string(mustEncodeBundle(t, a)) == string(mustEncodeBundle(t, b)) +} + +// TestBundleCarriesTheReadingStampOfItsRun is the reading half of +// adr-2609021016275803: every bundle carries the per-run context stamp of the +// run it was assembled for — the reading kind, that run's id, and the first +// twelve hex digits of the sha256 over its own item set — so a transcript that +// retained the bundle retains the stamp, and the separation check can tell which +// run the session held. +func TestBundleCarriesTheReadingStampOfItsRun(t *testing.T) { + root := fixtureRepo(t) + for _, p := range AssemblingPositions() { + res := assembleFixture(t, root, p) + got, ok := sessionkind.Parse(res.Bundle.ContextStamp) + if !ok { + t.Fatalf("position %s: the bundle's context_stamp %q is not a stamp", p, res.Bundle.ContextStamp) + } + if got.Kind != sessionkind.Reading { + t.Errorf("position %s: the bundle is stamped for a %s session, want reading", p, got.Kind) + } + if got.Run != res.RunID { + t.Errorf("position %s: the bundle is stamped for run %s and the assembly minted %s", p, got.Run, res.RunID) + } + items, err := encode(res.Bundle.Items) + if err != nil { + t.Fatal(err) + } + if want := sha256Hex(items)[:sessionkind.DigestLen]; got.Digest != want { + t.Errorf("position %s: the stamp's digest is %s, and the bundle's items hash to %s", p, got.Digest, want) + } + raw, err := EncodeBundle(res.Bundle) + if err != nil { + t.Fatal(err) + } + if found := sessionkind.Find(raw); len(found) != 1 || found[0] != res.Bundle.ContextStamp { + t.Errorf("position %s: the encoded bundle carries the stamps %q, want exactly its own", p, found) + } + } +} + // TestManifestCoversEveryBundleItem is itd-183's fourth criterion: every item // passed appears in the manifest with its path, its field where projection // occurred, and a hash. diff --git a/internal/core/reading/scope_test.go b/internal/core/reading/scope_test.go index 1abfaa761..2d051c3e8 100644 --- a/internal/core/reading/scope_test.go +++ b/internal/core/reading/scope_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/intentdriven/abcd/internal/core/sessionkind" "github.com/intentdriven/abcd/internal/gittest" ) @@ -574,11 +575,24 @@ func TestNoBundleFieldIsAPresetSelector(t *testing.T) { t.Fatalf("encode: %v", err) } var doc struct { - Preset map[string]any `json:"preset"` + Preset map[string]any `json:"preset"` + ContextStamp string `json:"context_stamp"` } if err := json.Unmarshal(raw, &doc); err != nil { t.Fatalf("decode: %v", err) } + // The context stamp is the one other top-level value a run chooses, and it + // is a token rather than a selector: it parses whole as a stamp, so it can + // name no record, no path and no material kind (adr-2609021016275803). + if _, ok := sessionkind.Parse(doc.ContextStamp); !ok { + t.Errorf("the bundle's context_stamp %q is not a stamp; anything else a run writes there "+ + "is a channel a selector could ride", doc.ContextStamp) + } + for _, k := range Kinds() { + if doc.ContextStamp == string(k) { + t.Errorf("the bundle's context_stamp is the material kind %q", k) + } + } allowed := map[string]bool{"kinds": true, "records": true, "location_narrowings": true} for key := range doc.Preset { if !allowed[key] { @@ -1088,8 +1102,7 @@ func TestRunIsReproducibleFromCommitAndPreset(t *testing.T) { if err != nil { t.Fatalf("second assembly at %s: %v", p, err) } - a, b := mustEncodeBundle(t, first.Bundle), mustEncodeBundle(t, second.Bundle) - if string(a) != string(b) { + if !identicalButForRun(t, first.Bundle, second.Bundle) { t.Errorf("two assemblies at %s of one commit produced different bundles; the "+ "invocation carries nothing a re-run could differ on", p) } diff --git a/internal/core/reading/size_test.go b/internal/core/reading/size_test.go index 40fba2c49..95f94e3ac 100644 --- a/internal/core/reading/size_test.go +++ b/internal/core/reading/size_test.go @@ -270,8 +270,11 @@ func TestBundleGainsNoFieldFromTheReport(t *testing.T) { // handed a tenth of it reports the missing nine tenths as a finding. What // must stay out is the size report, which is the operator's fact and not // the reading's. - want := map[string]bool{"_type": true, "schema_version": true, "position": true, - "preset": true, "items": true} + // "context_stamp" is expected too: it is the per-run token a transcript + // retains so the separation check can see which run a session held + // (adr-2609021016275803). It is a token parsed exactly, never a report. + want := map[string]bool{"_type": true, "schema_version": true, "context_stamp": true, + "position": true, "preset": true, "items": true} for key := range top { if !want[key] { t.Errorf("the bundle carries the top-level key %q; the report rides on the result alone", key) diff --git a/internal/core/reading/window_test.go b/internal/core/reading/window_test.go index 9dae678ca..932cc07b9 100644 --- a/internal/core/reading/window_test.go +++ b/internal/core/reading/window_test.go @@ -737,7 +737,7 @@ func TestTwoAssembliesOfOneEntryAreByteIdentical(t *testing.T) { for _, p := range AssemblingPositions() { first := assembleFixture(t, root, p) second := assembleFixture(t, root, p) - if string(mustEncodeBundle(t, first.Bundle)) != string(mustEncodeBundle(t, second.Bundle)) { + if !identicalButForRun(t, first.Bundle, second.Bundle) { t.Errorf("two assemblies of the committed %s entry produced different bundles", p) } a := decodedManifest(t, first.Manifest) diff --git a/internal/core/sessionkind/sessionkind.go b/internal/core/sessionkind/sessionkind.go new file mode 100644 index 000000000..2ece84054 --- /dev/null +++ b/internal/core/sessionkind/sessionkind.go @@ -0,0 +1,132 @@ +// Package sessionkind is the per-run context stamp: the one token a reading +// bundle and a scribe context each carry, naming the kind of session the +// context is for, the run it belongs to and a digest of what it holds +// (adr-2609021016275803, spc-2609020626045177). +// +// It is a LEAF, and that is the reason it exists as a package at all. Two +// packages read the stamp: core/reading writes it into the bundle, and +// core/history recognises it in a transcript at capture and checks separation +// over what it recorded. core/scribe writes it too. None of the three may import +// another for it — history importing reading would pull the whole assembler +// into the transcript store — so the grammar lives here, once, and each of them +// imports this. +// +// The stamp is PER RUN on purpose. A fixed token per kind would sit committed in +// the documentation and the code, so any session that read either would carry +// both kinds and be reported in breach, and a session that reached the ledger by +// another route would carry one and be reported clean; the ADR rejects that +// mechanism by name. A stamp names one run and a digest, and it is matched +// exactly, so a session that reads the docs carries none and a transcript +// carrying the reading stamp of one run and the scribe stamp of another is not +// a violation. +package sessionkind + +import ( + "fmt" + "regexp" + + "github.com/intentdriven/abcd/internal/core/recordid" +) + +// Kind is the kind of session a context is assembled for. The set is closed. +type Kind string + +// The two kinds. +const ( + Reading Kind = "reading" + Scribe Kind = "scribe" +) + +// Prefix opens every stamp. It is carried in the documentation, which is why a +// prefix alone is never a stamp: StampRe requires a kind, a well-formed run and +// a full digest behind it. +const Prefix = "abcd.context-stamp" + +// DigestLen is how many hex digits of the context's sha256 a stamp carries. +const DigestLen = 12 + +// StampRe is the ONE expression that recognises a stamp. Find adds the +// boundaries RE2 cannot express as lookarounds; every reader goes through Find +// or Parse rather than this expression bare. +var StampRe = regexp.MustCompile(`abcd\.context-stamp/(reading|scribe)/(rdg-[0-9]+)/([0-9a-f]{12})`) + +// exactRe is StampRe anchored, for Parse. +var exactRe = regexp.MustCompile(`^` + StampRe.String() + `$`) + +// Parsed is one stamp taken apart. +type Parsed struct { + Kind Kind + Run string + Digest string +} + +// Stamp renders the stamp for one context: kind, run, and the first DigestLen +// hex digits of the context's sha256. It refuses a kind outside the closed set, +// a run that is not a reading run id and a digest that is not hex or is too +// short to cut, so no caller can mint a stamp Parse would not read back. +func Stamp(kind Kind, run, sha256Hex string) (string, error) { + if kind != Reading && kind != Scribe { + return "", fmt.Errorf("sessionkind: kind %q is not one of %q, %q", kind, Reading, Scribe) + } + if !recordid.ValidReadingRunID(run) { + return "", fmt.Errorf("sessionkind: run %q is not a reading run id (rdg-N)", run) + } + if len(sha256Hex) < DigestLen { + return "", fmt.Errorf("sessionkind: digest %q is shorter than %d hex digits", sha256Hex, DigestLen) + } + s := Prefix + "/" + string(kind) + "/" + run + "/" + sha256Hex[:DigestLen] + if !exactRe.MatchString(s) { + return "", fmt.Errorf("sessionkind: digest %q is not lowercase hex", sha256Hex) + } + return s, nil +} + +// Parse reads one stamp, exactly: the whole string must be a stamp and nothing +// else. +func Parse(s string) (Parsed, bool) { + m := exactRe.FindStringSubmatch(s) + if m == nil { + return Parsed{}, false + } + return Parsed{Kind: Kind(m[1]), Run: m[2], Digest: m[3]}, true +} + +// Find returns every distinct stamp in b, in first-seen order. +// +// A match counts only between boundaries: the character before it may not +// continue a name (so `xabcd.context-stamp/...` is not a stamp) and the +// character after it may not continue the digest or the run (so a thirteenth hex +// digit, or any letter, disqualifies it). Without the trailing boundary a longer +// digest would be read as a shorter stamp that is not the one written. +func Find(b []byte) []string { + var out []string + seen := map[string]bool{} + for _, loc := range StampRe.FindAllIndex(b, -1) { + start, end := loc[0], loc[1] + if start > 0 && continuesName(b[start-1]) { + continue + } + if end < len(b) && continuesName(b[end]) && b[end] != '.' { + continue + } + s := string(b[start:end]) + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + return out +} + +// continuesName reports whether c would continue a token a stamp is part of. A +// full stop after the digest is a sentence ending rather than a continuation, so +// Find admits it there and only there. +func continuesName(c byte) bool { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + return true + case c == '_' || c == '-' || c == '.': + return true + } + return false +} diff --git a/internal/core/sessionkind/sessionkind_test.go b/internal/core/sessionkind/sessionkind_test.go new file mode 100644 index 000000000..e1c80828f --- /dev/null +++ b/internal/core/sessionkind/sessionkind_test.go @@ -0,0 +1,110 @@ +package sessionkind + +import ( + "strings" + "testing" +) + +const ( + digestA = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + digestB = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" +) + +// TestStampsArePerRunAndMatchedExactly holds the two properties the ADR rests +// the separation check on: a stamp names ONE run, and two stamps are the same +// stamp only when kind, run and digest all agree. A stamp that did not carry +// the run would put every reading session and every scribe session in one +// bucket, which is the fixed-token mechanism adr-2609021016275803 rejected. +func TestStampsArePerRunAndMatchedExactly(t *testing.T) { + a, err := Stamp(Reading, "rdg-2609250000000001", digestA) + if err != nil { + t.Fatal(err) + } + if want := "abcd.context-stamp/reading/rdg-2609250000000001/0123456789ab"; a != want { + t.Fatalf("Stamp = %q, want %q", a, want) + } + b, err := Stamp(Scribe, "rdg-2609250000000001", digestB) + if err != nil { + t.Fatal(err) + } + other, err := Stamp(Scribe, "rdg-2609250000000002", digestB) + if err != nil { + t.Fatal(err) + } + + pa, ok := Parse(a) + if !ok || pa.Kind != Reading || pa.Run != "rdg-2609250000000001" || pa.Digest != "0123456789ab" { + t.Fatalf("Parse(%q) = %+v, %v", a, pa, ok) + } + pb, _ := Parse(b) + po, _ := Parse(other) + if pa.Run != pb.Run { + t.Fatalf("a reading and a scribe stamp of one run must name one run: %q vs %q", pa.Run, pb.Run) + } + if pb.Run == po.Run { + t.Fatalf("two runs must be two runs: %q", pb.Run) + } + // Exact matching: a prefix of a stamp, or a stamp with a longer digest, is + // not that stamp. + for _, bad := range []string{ + strings.TrimSuffix(a, "b"), + a + "c", + strings.Replace(a, "reading", "Reading", 1), + strings.Replace(a, "rdg-", "rdi-", 1), + } { + if p, ok := Parse(bad); ok { + t.Errorf("Parse(%q) = %+v, want it refused: a stamp is matched exactly", bad, p) + } + } + + // The stamp is built only from a well-formed run, a closed kind and a hex + // digest long enough to cut. + for _, c := range []struct { + kind Kind + run, digest string + }{ + {"judge", "rdg-1", digestA}, + {Reading, "rdg-../x", digestA}, + {Reading, "rdg-1", "0123"}, + {Reading, "rdg-1", "zz23456789abcdef"}, + } { + if s, err := Stamp(c.kind, c.run, c.digest); err == nil { + t.Errorf("Stamp(%q, %q, %q) = %q, want a refusal", c.kind, c.run, c.digest, s) + } + } +} + +// TestStampReRecognisesOnlyAStamp holds the one expression both the transcript +// store and the separation check read by. It finds every stamp in free text, +// finds nothing in text that merely names the prefix or a kind token, which is +// what the documentation and the code carry, and does not run a stamp on into +// the characters around it. +func TestStampReRecognisesOnlyAStamp(t *testing.T) { + a, _ := Stamp(Reading, "rdg-7", digestA) + b, _ := Stamp(Scribe, "rdg-7", digestB) + text := `{"context_stamp":"` + a + `"} and later "` + b + `".` + got := Find([]byte(text)) + if len(got) != 2 || got[0] != a || got[1] != b { + t.Fatalf("Find = %q, want [%q %q]", got, a, b) + } + // A full stop after the digest ends a sentence; it does not extend the stamp. + if got := Find([]byte("handed " + a + ".")); len(got) != 1 || got[0] != a { + t.Fatalf("Find over a stamp ending a sentence = %q, want [%q]", got, a) + } + // The same stamp twice is one stamp. + if got := Find([]byte(a + " " + a)); len(got) != 1 { + t.Fatalf("Find over one stamp twice = %q, want it once", got) + } + for _, prose := range []string{ + "abcd.context-stamp///", + "abcd.context-stamp/reading/rdg-N/0123456789ab", + "the reading and scribe kinds, rdg-7", + "abcd.context-stamp/reading/rdg-7/0123456789a", + "abcd.context-stamp/reading/rdg-7/0123456789abX", + "xabcd.context-stamp/reading/rdg-7/0123456789ab", + } { + if got := Find([]byte(prose)); len(got) != 0 { + t.Errorf("Find(%q) = %q, want nothing", prose, got) + } + } +} From 30a7fde331a61d003ef865f8c5ba93278ce03bd2 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:53:10 +0100 Subject: [PATCH 23/78] feat(history): record context stamps at capture and check session separation Capture scans the raw transcript for per-run context stamps before redaction and records every distinct one as the optional frontmatter key context_stamps; a record without it parses as before, so the record schema version does not move. SessionSeparation reads that metadata through List and never a body. A record carrying the reading stamp and the scribe stamp of one run is a violation named by session, record file and run; a store with stamped transcripts and none such says the property held and lists the runs it saw; a store with no transcript, or none stamped, is unobserved with the reason, never clean (adr-56). `abcd history separation` renders the report, read-only, exiting 1 on a violation; `history list`'s text render ends with the same line and its JSON stays an array. The smoke lane runs the verb under a fresh HOME. The plugin page, the chapter row (bucket audit: reality against brief invariant 15), the surface snapshot and the CLI reference move with it. Part of itd-2609020625402599 / spc-2609020626045177. Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/11-history.md | 19 +- .abcd/development/release/surface.json | 5 + commands/history.md | 35 ++- docs/reference/cli/commands.md | 6 + evals/smoke_test.go | 21 +- internal/core/history/history.go | 18 ++ internal/core/history/separation.go | 141 +++++++++++ internal/core/history/separation_test.go | 223 ++++++++++++++++++ internal/core/history/store.go | 30 +++ internal/surface/cli/history.go | 37 +++ .../cli/history_separation_surface_test.go | 85 +++++++ 11 files changed, 613 insertions(+), 7 deletions(-) create mode 100644 internal/core/history/separation.go create mode 100644 internal/core/history/separation_test.go create mode 100644 internal/surface/cli/history_separation_surface_test.go diff --git a/.abcd/development/brief/04-surfaces/11-history.md b/.abcd/development/brief/04-surfaces/11-history.md index a961378fe..242cc2b83 100644 --- a/.abcd/development/brief/04-surfaces/11-history.md +++ b/.abcd/development/brief/04-surfaces/11-history.md @@ -30,6 +30,7 @@ ahoy's registry stays under `~/.abcd/history/` and holds no transcripts. | `list` | — | shipped | | `migrate` | — | shipped | | `reconstruct` | — | shipped | +| `separation` | audit | shipped | | `show` | — | shipped | | `staged` | — | shipped | @@ -56,6 +57,16 @@ ahoy's registry stays under `~/.abcd/history/` and holds no transcripts. - **Draining** redacts and stores every staged transcript, then deletes the raw copy. It exits non-zero when anything failed, and this verb runs the backlog to completion where the session-start hook drains a bounded number. +- **The separation check** reports whether any retained transcript held both a + reading and the ledger of one run, which brief invariant 15 forbids. Every + reading bundle and every scribe context carries a per-run context stamp; capture + records the stamps a transcript carried as metadata, and the check reads that + metadata and never a body. It names a transcript carrying the reading stamp and + the scribe stamp of one run and exits non-zero; otherwise it says the property + held for the runs it saw, or that it is unobserved when no retained transcript + carries a stamp, and never that it is clean + ([adr-2609021016275803](../../decisions/adrs/2609021016275803-no-session-holds-both-a-reading-and-the-ledger-and-a-per-run.md)). + The listing's text render ends with the same one line. - **Ingesting** — redact and store transcripts that are already on disk, at the paths given, and were never captured. The **destination repository is an @@ -314,7 +325,7 @@ _Generated from the command tree; a drift test fails `go test` when this appendi ### `abcd history` -Sub-verbs: `abcd history capture`, `abcd history discard`, `abcd history drain`, `abcd history ingest`, `abcd history list`, `abcd history migrate`, `abcd history reconstruct`, `abcd history show`, `abcd history staged`. +Sub-verbs: `abcd history capture`, `abcd history discard`, `abcd history drain`, `abcd history ingest`, `abcd history list`, `abcd history migrate`, `abcd history reconstruct`, `abcd history separation`, `abcd history show`, `abcd history staged`. Flags: none. @@ -377,6 +388,12 @@ Sub-verbs: none. | `--mode` | string | | `--out` | string | +### `abcd history separation` + +Sub-verbs: none. + +Flags: none. + ### `abcd history show` Sub-verbs: none. diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 13e3f2eff..81e02b09e 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -976,6 +976,11 @@ } ] }, + { + "path": "abcd history separation", + "hidden": false, + "flags": [] + }, { "path": "abcd history show", "hidden": false, diff --git a/commands/history.md b/commands/history.md index b8ae46945..1b53a68f7 100644 --- a/commands/history.md +++ b/commands/history.md @@ -1,7 +1,7 @@ --- name: history -description: Manage the native session-transcript store for this repo by invoking the abcd binary. list, show and staged are read-only; capture, drain and ingest are the redacting write paths, migrate repairs records in place, reconstruct renders one session as an artefact plus telemetry, and discard permanently deletes one unredacted staged or quarantined transcript. list --session reaches one session's whole set — its main thread and every sub-agent it spawned. The store is user-level, keyed on the repo's root-commit SHA, and every stored transcript is redacted on write. -argument-hint: "list [--session ] | show | staged [--all-repos] | drain | discard --yes | capture | ingest [...] | migrate | reconstruct " +description: Manage the native session-transcript store for this repo by invoking the abcd binary. list, show, staged and separation are read-only, and separation reports whether any retained transcript held both a reading and the ledger of one run; capture, drain and ingest are the redacting write paths, migrate repairs records in place, reconstruct renders one session as an artefact plus telemetry, and discard permanently deletes one unredacted staged or quarantined transcript. list --session reaches one session's whole set — its main thread and every sub-agent it spawned. The store is user-level, keyed on the repo's root-commit SHA, and every stored transcript is redacted on write. +argument-hint: "list [--session ] | separation | show | staged [--all-repos] | drain | discard --yes | capture | ingest [...] | migrate | reconstruct " --- # `/abcd:history` — session-transcript store @@ -88,6 +88,37 @@ sub-agent's record holds the full session id, so the session identifier alone is enough and no filtering by hand is needed. An empty result names the session it found nothing for, so a mistyped id never reads as a repo with no transcripts. +The text listing ends with the session-separation line described under +Separation, computed over the whole store whatever the listing selected. The +JSON stays an array of records and carries no such line. + +## Separation + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" history separation --json +``` + +Report whether any retained transcript held both a reading and the ledger of +one run. Every reading bundle and every scribe context carries a per-run context +stamp naming its kind, its run and a digest of what it holds; capture records +the stamps a transcript carried as metadata, and this check reads that metadata +and never a body. It says one of three things, and say which to the user: + +- **`violations`** non-empty: each names a record `file`, its `session_id` and + the `run` whose reading stamp and scribe stamp it carries. The verb exits 1. + That session held both halves of the wall, which is what the scribe verb and + the reading verb exist to keep apart. +- **Held for what was seen**: no retained transcript carries two stamps of one + run. Report the `runs` it saw and how many of the `transcripts` were + `stamped`; it is a statement about those, not about every session that ever + ran. +- **`unobserved`** true: the store holds no transcript, or none carrying a + stamp. Report the `reason` and never call it clean. A host that assembles a + session's context before anything is retained is outside this check's reach, + and there the scribe definition's protocol remains the gate. + +Read-only; exits 0 unless a violation was found. + ## Show ```bash diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index fa69cb246..288ecfea5 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -733,6 +733,12 @@ Render one session — the main thread and every sub-agent — as one artefact p --out string directory to write .md and .telemetry.json into, or - for stdout (default ".") ``` +#### `abcd history separation` + +Report whether any retained transcript held both a reading and the ledger of one run + +**Usage:** `abcd history separation` + #### `abcd history show` Show one stored transcript's metadata and redacted body diff --git a/evals/smoke_test.go b/evals/smoke_test.go index c6bdca16e..ea679b2d3 100644 --- a/evals/smoke_test.go +++ b/evals/smoke_test.go @@ -77,13 +77,26 @@ func TestReadOnlyVerbsRun(t *testing.T) { cases := []struct { args []string wantZero bool // version/help must be 0; the bare status board may report non-zero + // ownHome runs the verb under a fresh HOME, for a verb that resolves a + // user-level store: the smoke lane must neither read nor create the + // operator's own. + ownHome bool }{ - {[]string{"--help"}, true}, - {[]string{"version"}, true}, - {[]string{}, false}, // bare status board: no panic, any exit + {[]string{"--help"}, true, false}, + {[]string{"version"}, true, false}, + {[]string{}, false, false}, // bare status board: no panic, any exit + // The session-separation check over an empty store reports the property + // unobserved and exits 0 (adr-2609021016275803, spc-2609020626045177). + {[]string{"history", "separation"}, true, true}, } for _, tc := range cases { - out, code := run(t, tc.args...) + var out string + var code int + if tc.ownHome { + out, code = runIn(t, "", []string{"HOME=" + t.TempDir()}, tc.args...) + } else { + out, code = run(t, tc.args...) + } label := "abcd " + strings.Join(tc.args, " ") if panicked(out) { t.Errorf("`%s` panicked:\n%s", label, out) diff --git a/internal/core/history/history.go b/internal/core/history/history.go index cd8fa2dca..dab4f4706 100644 --- a/internal/core/history/history.go +++ b/internal/core/history/history.go @@ -34,6 +34,7 @@ import ( "github.com/intentdriven/abcd/internal/adapter/gitleaks" "github.com/intentdriven/abcd/internal/adapter/scanner" + "github.com/intentdriven/abcd/internal/core/sessionkind" "github.com/intentdriven/abcd/internal/fsutil" ) @@ -92,6 +93,17 @@ type Record struct { // makes an adoption a property of the artefact rather than of a run's // output. AdoptedProject string `json:"adopted_project,omitempty"` + + // ContextStamps are the per-run context stamps the raw transcript carried + // when it was captured, distinct and in first-seen order + // (adr-2609021016275803). A session handed a reading bundle or a scribe + // context through a tool the host retains carries that context's stamp, and + // recording it here as METADATA is what lets the separation check stay out of + // the bodies, as brief invariant 15's consumer list requires. + // + // Optional on read, so a record captured before the field existed parses + // with none and recordSchemaVersion does not move. + ContextStamps []string `json:"context_stamps,omitempty"` } // CaptureMeta is everything Capture stamps onto a record besides the bytes and @@ -194,6 +206,11 @@ func Capture(repoRoot, rootSHA string, raw []byte, meta CaptureMeta) (CaptureRes sum := sha256.Sum256(raw) sourceSHA := hex.EncodeToString(sum[:]) + // The per-run context stamps are read off the RAW transcript, before + // redaction: a stamp carries no secret, and scanning what the store keeps + // would make the record's metadata depend on what a redactor happened to + // touch (adr-2609021016275803). + stamps := sessionkind.Find(raw) // Idempotency: re-capturing the SAME source for the SAME session, agent and // kind is a no-op. Keying on the source SHA alone would silently attribute a @@ -350,6 +367,7 @@ func Capture(repoRoot, rootSHA string, raw []byte, meta CaptureMeta) (CaptureRes SpawnAttribution: meta.SpawnAttribution, SpawnDepth: meta.SpawnDepth, AdoptedProject: meta.AdoptedProject, + ContextStamps: stamps, } if err := fsutil.WriteFileAtomic(path, marshalRecord(rec, body), 0o644); err != nil { return CaptureResult{}, fmt.Errorf("history: write record: %w", err) diff --git a/internal/core/history/separation.go b/internal/core/history/separation.go new file mode 100644 index 000000000..477b5e3b5 --- /dev/null +++ b/internal/core/history/separation.go @@ -0,0 +1,141 @@ +package history + +// The session-separation check (adr-2609021016275803, spc-2609020626045177). +// +// Brief invariant 15 states that no session holds both a reading and the +// ledger. The per-run context stamp is how a transcript shows which contexts +// its session held, and Capture records every stamp a transcript carried as +// metadata. This file reads that metadata and nothing else: it is the consumer +// the invariant enumerates as session-separation evidence, metadata only, never +// bodies. +// +// It reports three things and never a fourth. A violation is named. The +// property held for what was seen is said with the runs it saw. And a store +// that holds no transcript, or none carrying any stamp, is UNOBSERVED with the +// reason — never clean, because a check that saw nothing and a check that could +// see nothing must not produce the same artefact (adr-56). + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/core/sessionkind" +) + +// Violation is one retained transcript carrying both the reading stamp and the +// scribe stamp of one run, named by its session, its record file and the run. +// File is the record's basename, never a path. +type Violation struct { + SessionID string `json:"session_id"` + File string `json:"file"` + Run string `json:"run"` +} + +// SeparationReport is what the check saw. +type SeparationReport struct { + // Transcripts is how many records the store holds, and Stamped how many of + // them carry at least one stamp. + Transcripts int `json:"transcripts"` + Stamped int `json:"stamped"` + // Runs is every run any stamp named, sorted, so a clean report says what it + // examined. + Runs []string `json:"runs"` + Violations []Violation `json:"violations"` + // Unobserved is true when there was nothing to examine, and Reason says why. + Unobserved bool `json:"unobserved"` + Reason string `json:"reason,omitempty"` +} + +// The two unobserved reasons. +const ( + reasonNoTranscript = "the store holds no retained transcript, so whether any session held both a reading " + + "and the ledger cannot be observed here; the scribe definition's protocol remains the gate" + reasonNoStamp = "no retained transcript carries a context stamp, so no session is known to have held a " + + "reading bundle or a scribe context; a host that assembles context before anything is retained is " + + "outside this check's reach, and there the scribe definition's protocol remains the gate" +) + +// SessionSeparation runs the check over this repository's lane of the store. +// It reads record metadata through List and never opens a body for it. +func SessionSeparation(repoRoot, rootSHA string) (SeparationReport, error) { + records, err := List(repoRoot, rootSHA) + if err != nil { + return SeparationReport{}, err + } + return separationOf(records), nil +} + +// separationOf is the judgement, over metadata already read. +func separationOf(records []Record) SeparationReport { + rep := SeparationReport{Transcripts: len(records), Runs: []string{}, Violations: []Violation{}} + runs := map[string]bool{} + for _, r := range records { + kinds := map[string]map[sessionkind.Kind]bool{} + for _, s := range r.ContextStamps { + p, ok := sessionkind.Parse(s) + if !ok { + continue + } + if kinds[p.Run] == nil { + kinds[p.Run] = map[sessionkind.Kind]bool{} + } + kinds[p.Run][p.Kind] = true + runs[p.Run] = true + } + if len(kinds) > 0 { + rep.Stamped++ + } + held := make([]string, 0, len(kinds)) + for run, k := range kinds { + if k[sessionkind.Reading] && k[sessionkind.Scribe] { + held = append(held, run) + } + } + sort.Strings(held) + for _, run := range held { + rep.Violations = append(rep.Violations, Violation{ + SessionID: r.SessionID, File: filepath.Base(r.Path), Run: run, + }) + } + } + for run := range runs { + rep.Runs = append(rep.Runs, run) + } + sort.Strings(rep.Runs) + sort.SliceStable(rep.Violations, func(i, j int) bool { + if rep.Violations[i].Run != rep.Violations[j].Run { + return rep.Violations[i].Run < rep.Violations[j].Run + } + return rep.Violations[i].File < rep.Violations[j].File + }) + switch { + case rep.Transcripts == 0: + rep.Unobserved, rep.Reason = true, reasonNoTranscript + case rep.Stamped == 0: + rep.Unobserved, rep.Reason = true, reasonNoStamp + } + return rep +} + +// Summary is the report's one line, the same wherever it is rendered: the +// violations by name, or the property held with the runs seen, or the +// unobserved reason. It carries no path. +func (r SeparationReport) Summary() string { + switch { + case len(r.Violations) > 0: + named := make([]string, 0, len(r.Violations)) + for _, v := range r.Violations { + named = append(named, fmt.Sprintf("%s (session %s) holds both stamps of %s", v.File, v.SessionID, v.Run)) + } + return fmt.Sprintf("session separation BREACHED: %d retained transcript(s) carry a reading stamp and a "+ + "scribe stamp of one run: %s", len(r.Violations), strings.Join(named, "; ")) + case r.Unobserved: + return "session separation unobserved: " + r.Reason + default: + return fmt.Sprintf("session separation held for what was seen: no retained transcript carries two "+ + "stamps of one run (%d of %d transcript(s) stamped; runs seen: %s)", + r.Stamped, r.Transcripts, strings.Join(r.Runs, ", ")) + } +} diff --git a/internal/core/history/separation_test.go b/internal/core/history/separation_test.go new file mode 100644 index 000000000..07f8e53f9 --- /dev/null +++ b/internal/core/history/separation_test.go @@ -0,0 +1,223 @@ +package history + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/sessionkind" +) + +// The digests are arbitrary hex: the check matches stamps by kind and run, and +// the digest is part of the exact token a transcript carries. +const ( + sepDigestA = "aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000" + sepDigestB = "bbbbbbbbbbbb0000000000000000000000000000000000000000000000000000" +) + +func mustStamp(t *testing.T, kind sessionkind.Kind, run, digest string) string { + t.Helper() + s, err := sessionkind.Stamp(kind, run, digest) + if err != nil { + t.Fatal(err) + } + return s +} + +// captureTranscript captures one transcript whose body carries the given lines, +// as a host retains a tool result that read a bundle or a context. +func captureTranscript(t *testing.T, repoRoot, session string, lines ...string) Record { + t.Helper() + body := "user: go\n" + strings.Join(lines, "\n") + "\nassistant: done\n" + res, err := Capture(repoRoot, testRootSHA, []byte(body), CaptureMeta{SessionID: session, Kind: "native"}) + if err != nil { + t.Fatalf("Capture %s: %v", session, err) + } + if !res.Wrote { + t.Fatalf("Capture %s wrote nothing", session) + } + return res.Record +} + +// TestCaptureRecordsContextStamps is the custodian's half: at capture the raw +// transcript is scanned for stamps and every distinct one lands in the record's +// metadata, where the check reads it. A transcript that merely quotes the +// grammar carries none. +func TestCaptureRecordsContextStamps(t *testing.T) { + repoRoot, _ := setupStore(t) + reading := mustStamp(t, sessionkind.Reading, "rdg-2609250000000001", sepDigestA) + scribe := mustStamp(t, sessionkind.Scribe, "rdg-2609250000000002", sepDigestB) + + rec := captureTranscript(t, repoRoot, "sess-stamped", + `tool_result: {"context_stamp": "`+reading+`"}`, + `tool_result: {"context_stamp": "`+scribe+`"}`, + `tool_result: again `+reading) + want := []string{reading, scribe} + if !reflect.DeepEqual(rec.ContextStamps, want) { + t.Fatalf("Capture recorded %q, want %q", rec.ContextStamps, want) + } + listed, err := List(repoRoot, testRootSHA) + if err != nil { + t.Fatal(err) + } + if len(listed) != 1 || !reflect.DeepEqual(listed[0].ContextStamps, want) { + t.Fatalf("the stored record reads back %+v, want the stamps %q", listed, want) + } + + plain := captureTranscript(t, repoRoot, "sess-docs", + "assistant: the stamp reads abcd.context-stamp///") + if len(plain.ContextStamps) != 0 { + t.Fatalf("a transcript quoting the grammar recorded %q; only a stamp is a stamp", plain.ContextStamps) + } +} + +// TestARecordWithoutContextStampsStillParses holds the field optional on read: +// a record captured before it existed parses clean and carries no stamps, so the +// record schema version does not move. +func TestARecordWithoutContextStampsStillParses(t *testing.T) { + data := []byte("---\nschema: 3\nsession_id: s1\nroot_commit: " + testRootSHA + + "\ncaptured_at: 2026-09-01T00:00:00Z\nsource_kind: native\nsource_sha256: abc\n" + + "redacted_secrets: 0\nredacted_home_paths: 0\n---\nbody\n") + r, _, err := parseRecord(data) + if err != nil { + t.Fatalf("a record without context_stamps does not parse: %v", err) + } + if len(r.ContextStamps) != 0 { + t.Fatalf("a record without context_stamps read back %q", r.ContextStamps) + } + if out := string(marshalRecord(r, "body")); strings.Contains(out, fmContextStamps) { + t.Fatalf("a record with no stamps is written with the key anyway:\n%s", out) + } +} + +// TestSeparationNamesATranscriptCarryingBothStampsOfOneRun is ac-5. +func TestSeparationNamesATranscriptCarryingBothStampsOfOneRun(t *testing.T) { + repoRoot, _ := setupStore(t) + run := "rdg-2609250000000001" + rec := captureTranscript(t, repoRoot, "sess-both", + mustStamp(t, sessionkind.Reading, run, sepDigestA), + mustStamp(t, sessionkind.Scribe, run, sepDigestB)) + + rep, err := SessionSeparation(repoRoot, testRootSHA) + if err != nil { + t.Fatal(err) + } + if rep.Unobserved { + t.Fatalf("a store holding a stamped transcript reported unobserved: %+v", rep) + } + want := []Violation{{SessionID: "sess-both", File: filepath.Base(rec.Path), Run: run}} + if !reflect.DeepEqual(rep.Violations, want) { + t.Fatalf("violations = %+v, want %+v", rep.Violations, want) + } +} + +// TestSeparationIgnoresTwoStampsOfTwoRuns holds the exact match: a reading +// stamp of one run and a scribe stamp of another is not a session that held a +// reading and the ledger of one run. +func TestSeparationIgnoresTwoStampsOfTwoRuns(t *testing.T) { + repoRoot, _ := setupStore(t) + captureTranscript(t, repoRoot, "sess-two-runs", + mustStamp(t, sessionkind.Reading, "rdg-1", sepDigestA), + mustStamp(t, sessionkind.Scribe, "rdg-2", sepDigestB)) + rep, err := SessionSeparation(repoRoot, testRootSHA) + if err != nil { + t.Fatal(err) + } + if len(rep.Violations) != 0 { + t.Fatalf("two stamps of two runs reported as a violation: %+v", rep.Violations) + } + if !reflect.DeepEqual(rep.Runs, []string{"rdg-1", "rdg-2"}) { + t.Fatalf("runs = %q, want both", rep.Runs) + } +} + +// TestSeparationReportsNoTranscriptCarryingTwoStamps is ac-6: two transcripts, +// one stamp each, is the property held for what was seen, and the report says +// which runs it saw. +func TestSeparationReportsNoTranscriptCarryingTwoStamps(t *testing.T) { + repoRoot, _ := setupStore(t) + run := "rdg-2609250000000001" + captureTranscript(t, repoRoot, "sess-reading", mustStamp(t, sessionkind.Reading, run, sepDigestA)) + captureTranscript(t, repoRoot, "sess-scribe", mustStamp(t, sessionkind.Scribe, run, sepDigestB)) + + rep, err := SessionSeparation(repoRoot, testRootSHA) + if err != nil { + t.Fatal(err) + } + if rep.Unobserved || len(rep.Violations) != 0 { + t.Fatalf("report = %+v, want the property held with no violation", rep) + } + if rep.Transcripts != 2 || rep.Stamped != 2 { + t.Fatalf("report counts %d transcripts, %d stamped; want 2 and 2", rep.Transcripts, rep.Stamped) + } + if !reflect.DeepEqual(rep.Runs, []string{run}) { + t.Fatalf("runs = %q, want [%s]", rep.Runs, run) + } + if got := rep.Summary(); !strings.Contains(got, "no retained transcript carries two stamps of one run") || + !strings.Contains(got, run) { + t.Fatalf("summary %q does not say the property held for what it saw", got) + } +} + +// TestSeparationReportsAnEmptyStoreAsUnobserved is ac-7, and its sibling: a +// store holding transcripts none of which carries a stamp is unobserved too. A +// check that saw nothing and a check that could see nothing must not produce the +// same artefact as a clean one (adr-56). +func TestSeparationReportsAnEmptyStoreAsUnobserved(t *testing.T) { + repoRoot, _ := setupStore(t) + rep, err := SessionSeparation(repoRoot, testRootSHA) + if err != nil { + t.Fatal(err) + } + if !rep.Unobserved || rep.Reason == "" { + t.Fatalf("an empty store reported %+v, want unobserved with a reason", rep) + } + if strings.Contains(rep.Summary(), "no retained transcript carries") { + t.Fatalf("an empty store's summary %q reads as clean", rep.Summary()) + } + + captureTranscript(t, repoRoot, "sess-plain", "assistant: nothing stamped here") + rep, err = SessionSeparation(repoRoot, testRootSHA) + if err != nil { + t.Fatal(err) + } + if !rep.Unobserved || rep.Transcripts != 1 || rep.Stamped != 0 { + t.Fatalf("a store with no stamped transcript reported %+v, want unobserved", rep) + } +} + +// TestSeparationReadsMetadataOnly holds the check inside the consumer brief +// invariant 15 enumerates — session-separation evidence, metadata only, never +// bodies. Every record's body is rewritten to carry a violation the metadata +// does not, and the report does not move. +func TestSeparationReadsMetadataOnly(t *testing.T) { + repoRoot, _ := setupStore(t) + run := "rdg-2609250000000001" + captureTranscript(t, repoRoot, "sess-reading", mustStamp(t, sessionkind.Reading, run, sepDigestA)) + captureTranscript(t, repoRoot, "sess-scribe", mustStamp(t, sessionkind.Scribe, run, sepDigestB)) + before, err := SessionSeparation(repoRoot, testRootSHA) + if err != nil { + t.Fatal(err) + } + + records, err := List(repoRoot, testRootSHA) + if err != nil { + t.Fatal(err) + } + planted := mustStamp(t, sessionkind.Reading, "rdg-9", sepDigestA) + "\n" + + mustStamp(t, sessionkind.Scribe, "rdg-9", sepDigestB) + "\n" + for _, r := range records { + if err := os.WriteFile(r.Path, marshalRecord(r, planted), 0o644); err != nil { + t.Fatal(err) + } + } + after, err := SessionSeparation(repoRoot, testRootSHA) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatalf("the report moved when only the bodies did:\nbefore %+v\nafter %+v", before, after) + } +} diff --git a/internal/core/history/store.go b/internal/core/history/store.go index 9070f751f..fdb6b2116 100644 --- a/internal/core/history/store.go +++ b/internal/core/history/store.go @@ -11,6 +11,7 @@ import ( "syscall" "time" + "github.com/intentdriven/abcd/internal/core/sessionkind" "github.com/intentdriven/abcd/internal/fsutil" ) @@ -202,6 +203,11 @@ const ( // Adoption (schema 3). fmAdoptedProject = "adopted_project" + + // The per-run context stamps a transcript carried, comma-joined. Optional + // and omitted when empty, so it does not move the schema version: every + // reader parses by field presence (adr-2609021016275803). + fmContextStamps = "context_stamps" ) // marshalRecord renders a record file: YAML frontmatter then the redacted body. @@ -234,6 +240,9 @@ func marshalRecord(r Record, body string) []byte { if r.SpawnDepth > 0 { fmt.Fprintf(&b, "%s: %d\n", fmSpawnDepth, r.SpawnDepth) } + if stamps := wellFormedStamps(r.ContextStamps); len(stamps) > 0 { + fmt.Fprintf(&b, "%s: %s\n", fmContextStamps, strings.Join(stamps, ",")) + } b.WriteString("---\n") b.WriteString(marshalBody(body)) return []byte(b.String()) @@ -306,9 +315,30 @@ func parseRecord(data []byte) (Record, string, error) { r.SpawnAttribution = fields[fmSpawnAttribution] r.AdoptedProject = fields[fmAdoptedProject] r.SpawnDepth, _ = strconv.Atoi(fields[fmSpawnDepth]) + if raw := fields[fmContextStamps]; raw != "" { + r.ContextStamps = wellFormedStamps(strings.Split(raw, ",")) + } return r, body, nil } +// wellFormedStamps keeps the entries that are stamps, distinct and in order. +// Anything else in the field — a hand edit, a truncation — is not evidence of a +// context a session held, so it is dropped on both the write and the read +// rather than counted. +func wellFormedStamps(in []string) []string { + var out []string + seen := map[string]bool{} + for _, s := range in { + s = strings.TrimSpace(s) + if _, ok := sessionkind.Parse(s); !ok || seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + return out +} + // listRecords reads every *.md record under tdir, newest first. A record file // that fails to parse is skipped (an individual corrupt transcript is not // fatal to the rest), mirroring ScanBundle's per-file tolerance. diff --git a/internal/surface/cli/history.go b/internal/surface/cli/history.go index 89495a1b0..4defc2beb 100644 --- a/internal/surface/cli/history.go +++ b/internal/surface/cli/history.go @@ -157,6 +157,13 @@ func newHistoryCommand(asJSON *bool) *cobra.Command { fmt.Fprintf(w, "\n%d record(s) for session %s — the main thread first, then every sub-agent it spawned.\n", len(records), termsafe.Sanitize(listSession)) } + // The session-separation line, over the whole store whatever the + // listing selected: the property is the store's, not a session's + // (adr-2609021016275803). The JSON stays an array, so it carries no + // such line and a consumer of it is untouched. + if rep, err := history.SessionSeparation(repoRoot, rootSHA); err == nil { + fmt.Fprintf(w, "\n%s\n", termsafe.Sanitize(rep.Summary())) + } }) }, } @@ -164,6 +171,36 @@ func newHistoryCommand(asJSON *bool) *cobra.Command { "list one session's whole set — its main-thread record and every sub-agent it spawned, main thread first") historyCmd.AddCommand(listCmd) + // separation — the session-separation check (adr-2609021016275803, + // spc-2609020626045177). Read-only, and reads record metadata only. A + // retained transcript carrying the reading stamp and the scribe stamp of one + // run is a finding, so the verb exits 1 after rendering it; the property held + // for what was seen, and the property unobserved, both exit 0 and say which. + historyCmd.AddCommand(&cobra.Command{ + Use: "separation", + Short: "Report whether any retained transcript held both a reading and the ledger of one run", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + repoRoot, rootSHA, err := historyStore(cmd) + if err != nil { + return err + } + rep, err := history.SessionSeparation(repoRoot, rootSHA) + if err != nil { + return err + } + if err := render(cmd.OutOrStdout(), *asJSON, rep, func(w io.Writer) { + fmt.Fprintf(w, "abcd history — %s\n", termsafe.Sanitize(rep.Summary())) + }); err != nil { + return err + } + if len(rep.Violations) > 0 { + return &exitError{Code: 1} + } + return nil + }, + }) + // staged — what ended but is not yet stored. This is the outcome axis the // store never had: before staging existed, "absent from the store" spanned // never-ended, ended-before-the-store-existed and ended-and-lost, and nothing diff --git a/internal/surface/cli/history_separation_surface_test.go b/internal/surface/cli/history_separation_surface_test.go new file mode 100644 index 000000000..9605c15be --- /dev/null +++ b/internal/surface/cli/history_separation_surface_test.go @@ -0,0 +1,85 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/history" + "github.com/intentdriven/abcd/internal/core/sessionkind" +) + +// history_separation_surface_test.go — the front door onto the session +// separation check (adr-2609021016275803, spc-2609020626045177): `history +// separation` renders the report, and `history list` ends with its one line. + +// TestHistorySeparationRendersUnobservedOnAnEmptyStore: a store with nothing in +// it says the property is unobserved, and never reads as clean. +func TestHistorySeparationRendersUnobservedOnAnEmptyStore(t *testing.T) { + repo, _ := sessionEndRepo(t) + t.Chdir(repo) + + var stdout, stderr bytes.Buffer + if code := Run([]string{"history", "separation"}, &stdout, &stderr); code != 0 { + t.Fatalf("history separation exited %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "unobserved") { + t.Fatalf("an empty store rendered %q, want it unobserved", stdout.String()) + } + + stdout.Reset() + if code := Run([]string{"history", "separation", "--json"}, &stdout, &stderr); code != 0 { + t.Fatalf("history separation --json exited %d: %s", code, stderr.String()) + } + var rep history.SeparationReport + if err := json.Unmarshal(stdout.Bytes(), &rep); err != nil { + t.Fatalf("--json is not a report: %v\n%s", err, stdout.String()) + } + if !rep.Unobserved || rep.Reason == "" { + t.Fatalf("--json reported %+v, want unobserved with a reason", rep) + } +} + +// TestHistorySeparationNamesABreachAndExitsOne: a retained transcript holding +// both stamps of one run is named by session, and the verb exits 1 — a found +// breach is a finding, not a render. +func TestHistorySeparationNamesABreachAndExitsOne(t *testing.T) { + repo, rootSHA := sessionEndRepo(t) + t.Chdir(repo) + digest := strings.Repeat("c", 64) + reading, _ := sessionkind.Stamp(sessionkind.Reading, "rdg-5", digest) + scribe, _ := sessionkind.Stamp(sessionkind.Scribe, "rdg-5", digest) + if _, err := history.Capture(repo, rootSHA, []byte("tool: "+reading+"\ntool: "+scribe+"\n"), + history.CaptureMeta{SessionID: "sess-held-both", Kind: "native"}); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := Run([]string{"history", "separation"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("a breach exited %d, want 1\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + } + if out := stdout.String(); !strings.Contains(out, "sess-held-both") || !strings.Contains(out, "rdg-5") { + t.Fatalf("the breach render %q does not name the session and the run", out) + } + + // The list's text render ends with the same line, and its JSON stays an + // array so a consumer of it is untouched. + stdout.Reset() + if code := Run([]string{"history", "list"}, &stdout, &stderr); code != 0 { + t.Fatalf("history list exited %d: %s", code, stderr.String()) + } + lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n") + if last := lines[len(lines)-1]; !strings.Contains(last, "session separation") || !strings.Contains(last, "sess-held-both") { + t.Fatalf("history list ends with %q, want the separation line", last) + } + stdout.Reset() + if code := Run([]string{"history", "list", "--json"}, &stdout, &stderr); code != 0 { + t.Fatalf("history list --json exited %d: %s", code, stderr.String()) + } + var records []history.Record + if err := json.Unmarshal(stdout.Bytes(), &records); err != nil { + t.Fatalf("history list --json is no longer an array: %v\n%s", err, stdout.String()) + } +} From c9aa577012db6a36c8f9dde974c5754e826581a0 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:13:31 +0100 Subject: [PATCH 24/78] feat(scribe): assemble the scribe's context from the ledger and ingest its output `abcd scribe` is a top-level verb, never a sub-verb of `reading`, because the two contexts must never share a front door. `scribe assemble --run --dispositions ` requires an ingested run (its commit marker) and builds the context by positive inclusion from AllowList(), derived from issueschema.LedgerDirs under the ledger root: the reading, disposition, admission, surprise and reframe stores and the three status directories. The collector walks nothing else and refuses a symlinked directory or leaf; assertAllowList refuses any item outside the list by path prefix whatever route it arrived by (a seam proves it). The supplied text is read whole, with the home directory and the repository root scrubbed. The context (abcd.scribe.context/1) carries the scribe's per-run stamp over its ledger array; the manifest (abcd.scribe.manifest/1) names every path with its length and hash, the definition, context and supplied hashes, the allow list and the exclusions. Both are parked in the local tier or under an --out a reading's include table cannot reach (reading exports RefuseReachableOutDir for this); nothing durable is touched. `scribe ingest --scribe-json ` proves the context against its parked manifest and the payload's cited hash, then refuses anything the scribe authored: a key outside the closed shapes (named, with its entry), an item the supplied text never names, a ground, exit condition or surprise not standing verbatim in it after whitespace folding, an answer outside the run or twice, and silence over an item of the run with no standing disposition. A run whose manifest is already promoted is refused before anything lands (write-once). Dispositions, admissions and surprises are then written through capture.Disposition, Admit and Surprise, inheriting the ordering gate and the one-ground rule; the first refusal stops the ingest and names what landed. Flags and refusals ride the result only. The manifest is promoted last through reading.WriteRunArtefact. The plugin page states the host obligation; the brief gains chapter 31 (row 24 is decide's, so the next free number), its index row and the seventh usage-only parent; the surface snapshot, CLI reference, release-gate pin and internals README move with it. Part of itd-2609020625402599 / spc-2609020626045177. Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/31-scribe.md | 164 +++++ .abcd/development/brief/04-surfaces/README.md | 8 +- .abcd/development/release-gate/manifest.json | 7 +- .abcd/development/release/surface.json | 59 ++ commands/scribe.md | 125 ++++ docs/reference/cli/commands.md | 83 +++ internal/README.md | 21 + internal/core/reading/assemble.go | 13 +- internal/core/scribe/assemble.go | 348 +++++++++++ internal/core/scribe/assemble_test.go | 312 ++++++++++ internal/core/scribe/fixture_test.go | 144 +++++ internal/core/scribe/ingest.go | 579 ++++++++++++++++++ internal/core/scribe/ingest_test.go | 467 ++++++++++++++ internal/core/scribe/scribe.go | 257 ++++++++ internal/surface/cli/cli.go | 1 + internal/surface/cli/scribe.go | 226 +++++++ internal/surface/cli/scribe_surface_test.go | 209 +++++++ 17 files changed, 3016 insertions(+), 7 deletions(-) create mode 100644 .abcd/development/brief/04-surfaces/31-scribe.md create mode 100644 commands/scribe.md create mode 100644 internal/core/scribe/assemble.go create mode 100644 internal/core/scribe/assemble_test.go create mode 100644 internal/core/scribe/fixture_test.go create mode 100644 internal/core/scribe/ingest.go create mode 100644 internal/core/scribe/ingest_test.go create mode 100644 internal/core/scribe/scribe.go create mode 100644 internal/surface/cli/scribe.go create mode 100644 internal/surface/cli/scribe_surface_test.go diff --git a/.abcd/development/brief/04-surfaces/31-scribe.md b/.abcd/development/brief/04-surfaces/31-scribe.md new file mode 100644 index 000000000..ff906ad4c --- /dev/null +++ b/.abcd/development/brief/04-surfaces/31-scribe.md @@ -0,0 +1,164 @@ +# `/abcd:scribe` — The Ledger Scribe's Context and Ingest + +The scribe ([`agents/scribe.md`](../../../../agents/scribe.md)) transcribes a +reading run's records and the researcher's dispositions into the ledger's +declared shapes, and authors nothing. Its access rule is the reading +assembler's exact inverse: a reading receives a positively included slice of the +shipped repository and no ledger; the scribe receives ledger content and no +shipped tree. `/abcd:scribe` is the verb that holds that rule by construction, +on the idiom [`/abcd:reading`](23-reading.md) holds the read block by: an +assembler with an allow list, a manifest that makes the exclusion checkable, and +an ingest that validates the returned output before anything is written +([adr-2609021016275803](../../decisions/adrs/2609021016275803-no-session-holds-both-a-reading-and-the-ledger-and-a-per-run.md); +itd-2609020625402599). + +It is a verb of its own and never a sub-verb of `/abcd:reading`, because the two +contexts must never share a front door. + +## Sub-verbs + +> _Machine-checked (`surface_coverage`, spc-27): each row records the verb's +> adr-40 bucket (`lint` / `review` / `audit` / `gate`, or `—` for a +> non-assessment verb) and its existence (`shipped` / `staged`). The existence +> fact is verified against the committed command-tree snapshot in both +> directions. The bucket cell is checked for membership of the closed adr-40 +> vocabulary only: the snapshot carries no bucket field, so a bucket that is +> wrong but legal passes, and that cell stays a review-grain claim._ + +| Verb | Bucket | Status | +|---|---|---| +| `assemble` | — | shipped | +| `ingest` | — | shipped | + +## Building the context + +Assembly takes one ingested reading run and the researcher's dispositions text. +The run must carry its commit marker: the scribe transcribes dispositions +against records the ledger already holds, so the run's reading records come from +the store, never from a raw reading output handed over a second time. + +The context is positive inclusion at directory grain. Its allow list is derived +from the issue ledger's own directory list — the reading records, dispositions, +admissions, surprises and reframes, and the three status directories — so a +record family the ledger declares later is inside the scribe's world, and +outside every reading's, by the same declaration. The collector walks those +directories and nothing else and refuses a symlink inside them; an allow-list +assertion then refuses any item whose path lies outside the list, whatever route +it arrived by, so an item that reached the context by a future route is a +refusal rather than a disclosure. The shipped tree, the brief, the intents, the +specs, the decisions, the local tier and the session-transcript store are +excluded because no walk starts in them. The supplied text is carried verbatim, +with the caller's home directory and the repository root taken out of it. + +The context and its manifest are parked in the local tier, or in an +operator-named directory that must be empty and may not be one a reading's +include table reaches: a context parked there would hand the next reading the +ledger. The manifest names every ledger path passed with its length and hash, +the hash of the scribe definition, the hash of the context and of the supplied +text, the allow list, and the exclusions with the signal behind each. Nothing in +the durable tier is touched, so a session assembled and never ingested leaves +no trace beside the run. + +Both artefacts carry the scribe's per-run context stamp, which names the scribe +kind, the run and a digest of the ledger the context holds. A session handed the +context carries the stamp in its retained transcript, and the history store's +separation check ([`11-history.md`](11-history.md)) reports any transcript +carrying the reading stamp and the scribe stamp of one run. + +## Ingesting what the scribe returned + +The scribe returns one JSON document naming its run and the context it was +handed, with four outputs: the records to file (dispositions, admissions and +surprises), fidelity flags, outstanding items, and refusals. Nothing is written +until all of the following hold: + +- **The context is proven.** The context on disk hashes to its parked manifest, + and the payload cites that hash. +- **Nothing is authored.** The payload is decoded against closed shapes at every + level, so a key the scribe may not author is refused by name with the entry it + sat on. A disposition or an admission for an item the supplied text never names + is one the researcher did not supply. A ground, an exit condition or a surprise + that does not stand verbatim in the supplied text, once whitespace is folded, + is one the researcher did not write. The scribe reformats; the check is that + every word it carries was already there. +- **Every answer is the run's, once.** An answered or outstanding item must be one + of the run's items, and one item takes one answer. +- **Nothing is passed over in silence.** Every item of the run with no standing + disposition is answered, listed as outstanding, or named in a refusal. An item + already answered in the ledger is not owed again, which is what lets a rerun + after a partial ingest drop what landed. +- **The run has no promoted scribe manifest.** The durable tier is write-once, so + the refusal comes before any write rather than after the records land. + +The records are then written in payload order — dispositions, admissions, +surprises — through the capture verbs' own functions, each under the ledger lock +it takes for itself and each with the redaction and refusals it already applies. +The verb adds no validation path of its own beyond the authoring refusal. Two +inherited refusals meet a scribe payload whole: the ordering gate refuses any +disposition or admission at the widening position until a committed comparative +run names the widening run, and the admission writer's one-ground rule refuses +an admission whose ground differs from the standing acceptance's. The first +refusal from any write stops the ingest and names what landed before it. + +Fidelity flags and refusals are carried into the result unresolved and never +into a record. Once every write has landed the manifest is promoted beside the +run through the reading store's one durable-tier writer, write-once. That +directory is denied to every assembly by the exclusion floor, so the next +reading cannot see it. + +Every refusal of either sub-verb exits 2; a refusal after something landed +renders what landed first. + +## Disclosed limits + +- The context is assembled from the ledger as it stands on disk. A scribe that + needs ledger content the working tree does not hold is outside this scope. +- The verb cannot enforce what a host hands a session. The plugin surface states + the obligation, and the separation check can only see what a host retained: + where a host assembles context before anything is retained, the check reports + the property unobserved and the scribe definition's protocol remains the gate. +- The state a disposition carries is judged by the capture verbs' vocabulary and + per-position rule, not against the supplied text: the verb refuses a ground the + researcher did not write, and the definition, not the verb, holds the scribe to + the state the researcher gave. + +## References + +- The definition: [`agents/scribe.md`](../../../../agents/scribe.md), and its + protocol in [`05-internals/01-agents.md`](../05-internals/01-agents.md). +- The decision: [adr-2609021016275803](../../decisions/adrs/2609021016275803-no-session-holds-both-a-reading-and-the-ledger-and-a-per-run.md). +- The plugin surface: `commands/scribe.md`. + + + +## Appendix: the shipped surface + +_Generated from the command tree; a drift test fails `go test` when this appendix and the tree disagree. It lists flags and sub-verbs only. What each flag means is in the [CLI reference](../../../../docs/reference/cli/commands.md), and exit codes, output fields and behaviour are the prose's to state._ + +### `abcd scribe` + +Sub-verbs: `abcd scribe assemble`, `abcd scribe ingest`. + +Flags: none. + +### `abcd scribe assemble` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--dispositions` | string | +| `--dry-run` | bool | +| `--out` | string | +| `--run` | string | + +### `abcd scribe ingest` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--context` | string | +| `--scribe-json` | string | + + diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index 16e52f892..544100f9f 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -43,6 +43,7 @@ are wiring rather than user-facing surface are listed separately under | 28 | `/abcd:peers` | shipped | See what the sibling worktrees and local branches hold before capturing, fixing or filing anything | [`08-abcd.md`](08-abcd.md) | | 29 | `/abcd:report` | shipped | Tell abcd about a defect or propose an enhancement from a repository it manages, into an inbox in your own account | [`29-report.md`](29-report.md) | | 30 | `/abcd:inbox` | shipped | Read the reports managed repositories filed, and promote one to a capture that names the sender only by its root-commit key | [`30-inbox.md`](30-inbox.md) | +| 31 | `/abcd:scribe` | shipped | Build the ledger scribe's context from the ledger alone, and ingest what it transcribed without letting it author anything | [`31-scribe.md`](31-scribe.md) | ## How much of this table a machine keeps honest @@ -124,8 +125,8 @@ read-only render of their own state, and close on the next move where there is one to name. It is a convention rather than a universal, and the exceptions are where the -tree does not yet meet its own discipline. Six parents print usage with no state -at all: `disembark`, `docs`, `embark`, `guard`, `history`, and `ideate`. Bare +tree does not yet meet its own discipline. Seven parents print usage with no state +at all: `disembark`, `docs`, `embark`, `guard`, `history`, `ideate`, and `scribe`. Bare `abcd launch` refuses with a hint to pass `--dry-run`. Bare `abcd decide` refuses because its one operand is the quoted title it mints a record from. And `abcd update` is a mutating fetch-verify-swap rather than a render at all. This @@ -175,7 +176,8 @@ documents is then an unknown command (iss-161). One file per verb, directly unde `abcd`, `ahoy`, `banlist`, `capture`, `consult`, `decide`, `disembark`, `docs`, `embark`, `guard`, `history`, `ideate`, `identity`, `implement`, `inbox`, `ingest`, `intent`, `launch`, `lint`, `memory`, `mode`, `peers`, -`prepare-this-repo`, `reading`, `report`, `site`, `update`, `version`. +`prepare-this-repo`, `reading`, `report`, `scribe`, `site`, `update`, +`version`. `abcd.md` is the bare `/abcd` status board; every other file is `/abcd:`. diff --git a/.abcd/development/release-gate/manifest.json b/.abcd/development/release-gate/manifest.json index 3e4632dba..93c22122a 100644 --- a/.abcd/development/release-gate/manifest.json +++ b/.abcd/development/release-gate/manifest.json @@ -46,6 +46,7 @@ ".abcd/development/brief/04-surfaces/27-implement.md", ".abcd/development/brief/04-surfaces/29-report.md", ".abcd/development/brief/04-surfaces/30-inbox.md", + ".abcd/development/brief/04-surfaces/31-scribe.md", ".abcd/development/brief/04-surfaces/README.md", ".abcd/development/brief/02-constraints/04-naming.md", ".abcd/development/brief/05-internals/01-agents.md", @@ -82,10 +83,10 @@ "probe": "list skills/ (abcd ships zero skills; the directory is empty or absent)" } ], - "checkerCount": 40, - "promptHash": "sha256:17fac8f45b9f29a778e950908bf97a4364426832a2d27b46a3368360f9124758", + "checkerCount": 41, + "promptHash": "sha256:975a1236a20cec178eed1ef57d0eefe25ff7678ffc4288bcf162f1032951824e", "prompt": { - "context": "Repo root: the current working directory — use repo-relative paths\nthroughout, never absolute local paths. Ground truth is the SHIPPED surface,\nverified empirically: build the binary (make build produces bin/abcd--)\nand run it (`abcd --help` and `abcd --help`), and list commands/, agents/\nand skills/. abcd currently ships ZERO skills — the whole /abcd: surface is\ncommands under commands/ (abcd, ahoy, banlist, capture, consult, decide, disembark, docs, embark, guard,\nhistory, ideate, identity, implement, inbox, ingest, intent, launch, lint, memory, mode, peers,\nprepare-this-repo, reading, report, site, update, version)\nand agent prompts under agents/ (cold-reading-comparative, cold-reading-detection, cold-reading-entailment,\ncold-reading-widening, docs-currency-reviewer, graveyard-interpreter,\nintent-auditor, lifeboat-reviewer, press-release-composer,\nprinciple-distiller, release-changelog-composer, ruthless-reviewer, scribe,\nsecurity-reviewer, sota-researcher);\nskills/ is empty or absent. The brief chapters that carry surface claims are\npinned in this manifest's briefDocs: .abcd/development/brief/04-surfaces/*.md\n(the index README included) and the 02-constraints, 05-internals and 06-delivery\nchapters that count or enumerate verbs, sub-verbs, agents, hooks and the plugin\ntree. That list says where to LOOK, not what may be REPORTED: a surface claim\nin any other brief chapter is in scope, and a real surface whose only\ndocumented home lies outside the pinned list is reported with that location.\nReport DISCREPANCIES ONLY — where record and reality disagree, or one side is\nmissing. A brief row explicitly marked staged (its Status column is \"staged\")\n/ probe-only / later-phase is NOT a discrepancy; an unmarked claim about a\nsurface that does not exist IS. Do not fix anything.", + "context": "Repo root: the current working directory — use repo-relative paths\nthroughout, never absolute local paths. Ground truth is the SHIPPED surface,\nverified empirically: build the binary (make build produces bin/abcd--)\nand run it (`abcd --help` and `abcd --help`), and list commands/, agents/\nand skills/. abcd currently ships ZERO skills — the whole /abcd: surface is\ncommands under commands/ (abcd, ahoy, banlist, capture, consult, decide, disembark, docs, embark, guard,\nhistory, ideate, identity, implement, inbox, ingest, intent, launch, lint, memory, mode, peers,\nprepare-this-repo, reading, report, scribe, site, update, version)\nand agent prompts under agents/ (cold-reading-comparative, cold-reading-detection, cold-reading-entailment,\ncold-reading-widening, docs-currency-reviewer, graveyard-interpreter,\nintent-auditor, lifeboat-reviewer, press-release-composer,\nprinciple-distiller, release-changelog-composer, ruthless-reviewer, scribe,\nsecurity-reviewer, sota-researcher);\nskills/ is empty or absent. The brief chapters that carry surface claims are\npinned in this manifest's briefDocs: .abcd/development/brief/04-surfaces/*.md\n(the index README included) and the 02-constraints, 05-internals and 06-delivery\nchapters that count or enumerate verbs, sub-verbs, agents, hooks and the plugin\ntree. That list says where to LOOK, not what may be REPORTED: a surface claim\nin any other brief chapter is in scope, and a real surface whose only\ndocumented home lies outside the pinned list is reported with that location.\nReport DISCREPANCIES ONLY — where record and reality disagree, or one side is\nmissing. A brief row explicitly marked staged (its Status column is \"staged\")\n/ probe-only / later-phase is NOT a discrepancy; an unmarked claim about a\nsurface that does not exist IS. Do not fix anything.", "directionA": "Direction A. Read ${doc} fully. Extract every checkable claim\nabout the shipped surface (verbs, sub-verbs, flags, skill names, counts,\nfile layouts, \"abcd ships N ...\" statements) and verify each against\nreality. Return item=\"${doc}\" and the discrepancy list.", "directionB": "Direction B. The real surface \"${s.name}\" (${s.kind}) exists:\ninspect it (${s.probe}). Search the brief's surface chapters for its\ndocumented home (grep .abcd/development/brief/). If no brief row documents\nit — or the brief documents it under a wrong name/shape — that is a\ndiscrepancy. Return item=\"${s.name}\" and the discrepancy list (empty if\nproperly documented)." } diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 81e02b09e..ab659a649 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -1724,6 +1724,65 @@ "hidden": false, "flags": [] }, + { + "path": "abcd scribe", + "hidden": false, + "flags": [] + }, + { + "path": "abcd scribe assemble", + "hidden": false, + "flags": [ + { + "name": "dispositions", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "dry-run", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, + { + "name": "out", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "run", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, + { + "path": "abcd scribe ingest", + "hidden": false, + "flags": [ + { + "name": "context", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "scribe-json", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, { "path": "abcd site", "hidden": false, diff --git a/commands/scribe.md b/commands/scribe.md new file mode 100644 index 000000000..cef7e46b5 --- /dev/null +++ b/commands/scribe.md @@ -0,0 +1,125 @@ +--- +name: scribe +description: Build the ledger scribe's context from the ledger alone and ingest what the scribe transcribed, by invoking the abcd binary. assemble parks the context and a hashed manifest in the local tier and touches nothing durable; ingest validates the scribe's output, refuses anything the scribe authored, writes dispositions, admissions and surprises through the capture verbs, and promotes the manifest beside the run. +argument-hint: "assemble --run --dispositions [--out ] [--dry-run] | ingest --scribe-json [--context ]" +--- + +# `/abcd:scribe` — the ledger scribe's context and ingest + +The scribe (the `abcd:scribe` agent) transcribes a reading run's records and the +researcher's dispositions into the ledger's declared shapes, and authors +nothing. Its access rule is the reading assembler's exact inverse: a reading is +handed a slice of the shipped repository and no ledger; the scribe is handed the +ledger and no shipped tree. This verb holds that rule by construction. It builds +the scribe's context from the issue ledger's own directories and nothing else, +writes a manifest naming every path it passed, and refuses a returned payload +that authored anything. + +Two things this surface does not do. It never runs the scribe: it produces the +context a scribe session is handed, and dispatching that session is host work. +And it never judges what it transcribes: a state, a ground or a resolution the +researcher's text does not carry is refused, never supplied. + +## The host obligation + +**Hand the scribe session the context file and nothing else.** No repository +access, no reading bundle, no transcript, no other file. The session is not the +reading session and never becomes one: a session that held a reading bundle may +not be handed a scribe context, and the reverse. The verb cannot enforce what a +host gives a session; `abcd history separation` reports afterwards whether any +retained transcript carries both a reading stamp and a scribe stamp of one run. + +## Assemble + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" scribe assemble --run --dispositions ./dispositions.md --json +``` + +`--run` names an ingested reading run: its commit marker must exist, because the +scribe transcribes dispositions against records the ledger already holds, read +from the store rather than from a raw reading output handed over again. +`--dispositions` names the researcher's dispositions text in whatever form they +wrote it; it is read whole and carried verbatim, with the home directory and the +repository root taken out of it. + +The context carries every record under the issue ledger's own directories — the +reading records, dispositions, admissions, surprises and reframes, and the open, +resolved and won't-fix issues — derived from the ledger's directory list, so a +record family the ledger declares later is included the day it is declared. +Nothing outside those directories is walked, a symlink inside them is refused, +and an item outside them is refused whatever route it arrived by. + +Report from the JSON: `run`, `item_count`, `context_stamp`, `context_sha256` +(the scribe's output cites it), `out_dir` and `artefacts`. The context and the +manifest are parked in `.abcd/.work.local/scratch/scribe-runs//`, or +under `--out`, which must be empty or absent and may not be a directory a +reading's include table reaches. The durable record is untouched: a session +assembled and never ingested leaves no trace beside the run. `--dry-run` writes +nothing unless `--out` names somewhere to write. A second assembly into an +occupied directory is refused: one directory holds one session's evidence. + +Every refusal exits 2: a run id that is not one, a run that was never ingested, +a missing `--dispositions`, an out directory a reading can reach, and a +symlinked ledger directory. + +## What the scribe returns + +One JSON document, which the scribe definition states for the session: + +```json +{ + "_type": "abcd.scribe.output/1", + "run": "rdg-N", + "context_sha256": "", + "dispositions": [{"item": "rdi-N", "state": "accepted", "grounds": "…", + "exit_condition": "", "supersedes": "", "recurs": []}], + "admissions": [{"item": "rdi-N", "grounds": "…"}], + "surprises": [{"occasioned_by": "rdi-N", "text": "…"}], + "fidelity_flags": [{"first": "…", "second": "…"}], + "outstanding": ["rdi-N"], + "refusals": [{"subject": "…", "reason": "…"}] +} +``` + +The scribe cannot compute `context_sha256`; the host puts the value +`assemble` reported into the payload, or hands it to the session with the +context. + +## Ingest + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" scribe ingest --scribe-json ./scribe-output.json --json +``` + +`--context` names the context when `assemble` wrote it under `--out`; the +manifest is read from beside it. Nothing is written until all of the following +hold, and any failure exits 2 naming the field and the item: + +- **The context is proven**: it hashes to its parked manifest, and the payload + cites that hash. A payload from another session is refused. +- **Nothing is authored**: a key outside the shapes above (a `resolution`, a + `pattern`, a `position`, anything) is refused by name; a disposition or an + admission for an item the supplied dispositions never name is refused; a + `grounds`, an `exit_condition` or a surprise `text` that does not stand + verbatim in the supplied text once whitespace is folded is refused. The scribe + reformats; it never adds a word. +- **Every answer is the run's**: a disposition, admission or outstanding item + that is not one of the run's items is refused, and one item takes one answer. +- **Nothing is passed over in silence**: every item of the run with no standing + disposition is answered, listed as outstanding, or named in a refusal. +- **The run has no promoted scribe manifest yet**: the durable tier is + write-once, so a later answer to the run is written with the capture verbs. + +Then the dispositions, the admissions and the surprises are written, in that +order, through the capture verbs' own functions, each with its own redaction and +refusals. At the widening position that includes the ordering gate: no +disposition and no admission lands until a committed comparative run names the +widening run. The first refusal stops the ingest, the render names what landed +before it, and the manifest stays parked, so a rerun drops what landed rather +than minting it twice. + +Report from the JSON: the `dispositions`, `admissions` and `surprises` written, +each with its id; `outstanding`; every `fidelity_flags` entry, **unresolved** — +never pick one side of a flag, it is the researcher's to resolve; every +`refusals` entry; and `manifest`, the promoted manifest beside the run. Flags +and refusals are never written into a record. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 288ecfea5..b539baf5d 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1530,6 +1530,89 @@ its rules replaced, its state changed, or a custom domain declared — renders a diagnostic, and carries "source": "repo" in --json; an untouched bundled domain renders bare and carries "source": "bundled". Read-only. +### `abcd scribe` + +Ledger scribe: assemble its context from the ledger alone, and ingest what it transcribed + +**Usage:** `abcd scribe` + +Build the ledger scribe's context and ingest what the scribe returns. + +The scribe transcribes a reading run's records and the researcher's dispositions into the +ledger's declared shapes, and authors nothing. Its context is the reading assembler's exact +inverse: ledger content only, drawn from the issue ledger's own directories, and the +researcher's supplied text. `assemble` builds it with a manifest of every path passed; +`ingest` validates the scribe's output and refuses anything the scribe authored. + +#### `abcd scribe assemble` + +Build a scribe session's context from the ledger and the supplied dispositions + +**Usage:** `abcd scribe assemble --run --dispositions [flags]` + +Build the context one scribe session is handed, for one ingested reading run. + +The context is positive inclusion at directory grain: the issue ledger's own directories +(its reading records, dispositions, admissions, surprises and reframes, and its three status +directories), derived from the ledger's directory list, and the researcher's dispositions +text read whole. Nothing else is walked, and an item outside that list is refused whatever +route it arrived by. The run must be ingested: its records come from the store, never from +a raw reading output handed over again. + +The context and a manifest naming every path passed, by hash, are parked in the local tier +(or under --out, which may not be a directory a reading's include table reaches). Nothing in +the durable record is touched. Both carry the scribe's per-run context stamp. + +**Flags:** + +``` + --dispositions string the researcher's dispositions text, read whole and carried verbatim + --dry-run write nothing; with --out the two artefacts still land in that directory + --out string an empty or absent directory the context and the manifest are written to + (default: the local-tier scribe run directory) + --run string the ingested reading run the session transcribes for (rdg-N) +``` + +**Example:** + +``` +abcd scribe assemble --run rdg-2609250000000001 --dispositions ./dispositions.md --json +``` + +#### `abcd scribe ingest` + +Validate a scribe session's output and write what it transcribed + +**Usage:** `abcd scribe ingest --scribe-json [flags]` + +Validate the JSON a scribe session returned and write its records through the capture verbs. + +The context the session was handed is proven first: it must hash to its parked manifest, and +the output must cite that hash. Then the output is refused if the scribe authored anything — +a field outside the declared shapes, an item the supplied dispositions never name, or a ground, +exit condition or surprise that does not stand verbatim in the supplied text once whitespace +is folded — or if it passes over an unanswered item of the run in silence. Nothing is written +until all of that holds. + +Dispositions, admissions and surprises are then written in that order through the capture verbs, +which apply their own redaction and refusals, the ordering gate included; the first refusal stops +the ingest and names what landed before it. Fidelity flags and refusals are reported and never +written. Once every write has landed the manifest is promoted beside the run, write-once. + +**Flags:** + +``` + --context string the context the session was handed, when assemble wrote it under --out + (default: the local-tier scribe run directory of the output's run) + --scribe-json string path to the JSON the scribe session returned +``` + +**Example:** + +``` +abcd scribe ingest --scribe-json ./scribe-output.json --json +``` + ### `abcd site` The website rendered from this repository: what is declared, and what was built (read-only) diff --git a/internal/README.md b/internal/README.md index f4c3cf839..e95898271 100644 --- a/internal/README.md +++ b/internal/README.md @@ -61,6 +61,16 @@ plugin surface, and a future MCP server share one engine. the families it admits. A leaf because `core/capture` imports `core/intent` and both need it; `core/capture` keeps its historical locator names as thin wrappers over it. +- **`core/sessionkind/`** — the per-run context stamp: the two session kinds + (`reading`, `scribe`), the one grammar a stamp is rendered and recognised by + (kind, reading run, and twelve hex digits of the context's sha256), and the + bounded finder that picks every stamp out of a text (adr-2609021016275803). A + leaf on the `core/grounds` precedent, because three packages read one token: + `core/reading` stamps the bundle, `core/scribe` stamps the context, and + `core/history` records the stamps a transcript carried and checks separation + over them. History importing either assembler to learn the grammar would pull + the assemblers into the transcript store, so the grammar lives here and each of + them imports it. - **`core/relink/`** — the one link-repoint primitive. A record's folder is its status, so every lifecycle transition is a rename, and a rename strands every relative link that named the file where it was. The verbs that move a record @@ -133,6 +143,17 @@ plugin surface, and a future MCP server share one engine. row's own source downward, so a record family added later is excluded by construction. Record enumeration is `core/lint`'s `LoadRecordGraph`, never a second parser. The package assembles input and never runs a reading. +- **`core/scribe/`** — the ledger scribe's context assembler and output ingest + (itd-2609020625402599, spc-2609020626045177): the reading assembler's inverse. + It builds the scribe's context by positive inclusion from an allow list DERIVED + from `core/issueschema`'s ledger directory list, refuses any item outside it by + path prefix whatever route it arrived by, and parks the context with a hashed + manifest in the local tier. Its ingest refuses a payload that authored anything + and writes through `core/capture`'s own verbs, adding no validation path beyond + the authoring refusal, then promotes the manifest beside the run through + `core/reading`'s one durable-tier writer. It is not inside `core/reading` + because the two contexts must never share a front door, and a package that + built both would be one. - **`core/decide/`** — the decision record's WRITE side: `abcd decide ""` mints an `adr-<stamp>` through `core/recordid` and lays the ADR skeleton under `.abcd/development/decisions/adrs/`. It is the last record family to reach that diff --git a/internal/core/reading/assemble.go b/internal/core/reading/assemble.go index 5038a2c9b..411fb62ae 100644 --- a/internal/core/reading/assemble.go +++ b/internal/core/reading/assemble.go @@ -812,6 +812,17 @@ func requireEmptyDir(named, dir string) error { // Only a directory inside the repository can be reached, so an output path that // resolves outside it is always fine. func refuseSelfAdmittingOutDir(repoRoot, outDir, label string) error { + return RefuseReachableOutDir(repoRoot, outDir, label, BundleFileName, ManifestFileName) +} + +// RefuseReachableOutDir refuses an output directory where any of the named +// files would be admitted by the include table at some position. It is the +// check refuseSelfAdmittingOutDir makes for this assembler's own two artefacts, +// exported for the one other assembler whose output must never become a +// reading's input: the scribe's context carries ledger content, and a context +// parked where the table reaches it is the next reading handed the ledger +// (spc-2609020626045177, brief invariant 15). +func RefuseReachableOutDir(repoRoot, outDir, label string, names ...string) error { if outDir == "" { return nil } @@ -836,7 +847,7 @@ func refuseSelfAdmittingOutDir(repoRoot, outDir, label string) error { if rel == ".." || strings.HasPrefix(rel, "../") { return nil } - for _, name := range []string{BundleFileName, ManifestFileName} { + for _, name := range names { candidate := path.Join(rel, name) for _, p := range Positions() { if Admits(p, candidate) { diff --git a/internal/core/scribe/assemble.go b/internal/core/scribe/assemble.go new file mode 100644 index 000000000..c6f22e76b --- /dev/null +++ b/internal/core/scribe/assemble.go @@ -0,0 +1,348 @@ +package scribe + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/reading" + "github.com/intentdriven/abcd/internal/core/recordid" + "github.com/intentdriven/abcd/internal/core/sessionkind" + "github.com/intentdriven/abcd/internal/fsutil" +) + +// AssembleRequest is one scribe assembly: the run, the researcher's supplied +// dispositions, and where the two artefacts go. +type AssembleRequest struct { + RepoRoot string + // Run is the ingested reading run the session transcribes for (rdg-N). + Run string + // DispositionsPath is the researcher's dispositions text, in whatever form + // they wrote it. It is the one supplied path, and it is read, never walked. + DispositionsPath string + // OutDir is where the context and the manifest go; empty means the local-tier + // default for the run. OutDirLabel is the operator's spelling of it, for + // refusals. + OutDir string + OutDirLabel string + // DryRun writes nothing unless OutDir names somewhere to write. + DryRun bool +} + +// AssembleResult is what one assembly produced. The two artefacts are carried +// for an in-memory caller and named by basename. +type AssembleResult struct { + Run string `json:"run"` + ContextStamp string `json:"context_stamp"` + ContextSHA256 string `json:"context_sha256"` + ItemCount int `json:"item_count"` + AllowList []string `json:"allow_list"` + OutDir string `json:"out_dir,omitempty"` + Artefacts []string `json:"artefacts"` + Written bool `json:"written"` + + Context Context `json:"-"` + Manifest Manifest `json:"-"` +} + +// collectHook is the collector, behind a seam so a test can inject an item by a +// route the allow list does not own and watch assertAllowList refuse it. +var collectHook = collectLedger + +// Assemble builds the scribe's context for one ingested run. +func Assemble(req AssembleRequest) (AssembleResult, error) { + if strings.TrimSpace(req.RepoRoot) == "" { + return AssembleResult{}, errors.New("scribe: no repository root given") + } + if err := requireCommittedRun(req.RepoRoot, req.Run); err != nil { + return AssembleResult{}, err + } + if strings.TrimSpace(req.DispositionsPath) == "" { + return AssembleResult{}, errors.New("scribe: no dispositions supplied; the scribe transcribes the " + + "researcher's dispositions and authors none, so an assembly without them has nothing to hand it") + } + label := req.OutDirLabel + if label == "" { + label = req.OutDir + } + if err := reading.RefuseReachableOutDir(req.RepoRoot, req.OutDir, label, + ContextFileName, ManifestFileName); err != nil { + return AssembleResult{}, fmt.Errorf("scribe: %w", err) + } + raw, err := fsutil.ReadGuarded(req.DispositionsPath, reading.MaxFileBytes) + if err != nil { + return AssembleResult{}, fmt.Errorf("scribe: reading the supplied dispositions: %w", err) + } + supplied := scrub(req.RepoRoot, string(raw)) + + entries, err := collectHook(req.RepoRoot) + if err != nil { + return AssembleResult{}, err + } + if err := assertAllowList(entries); err != nil { + return AssembleResult{}, err + } + if !holdsRun(entries, req.Run) { + return AssembleResult{}, fmt.Errorf("scribe: the ledger holds no reading record of %s, so there is "+ + "nothing for the scribe to transcribe dispositions against; a run with an empty item set has no "+ + "item to answer", req.Run) + } + + ledgerRaw, err := encode(entries) + if err != nil { + return AssembleResult{}, err + } + stamp, err := sessionkind.Stamp(sessionkind.Scribe, req.Run, sha256Hex(ledgerRaw)) + if err != nil { + return AssembleResult{}, fmt.Errorf("scribe: stamping the context: %w", err) + } + ctx := Context{ + Type: ContextType, SchemaVersion: SchemaVersion, ContextStamp: stamp, Run: req.Run, + Ledger: entries, Supplied: Supplied{Dispositions: supplied}, + } + contextRaw, err := encode(ctx) + if err != nil { + return AssembleResult{}, err + } + defHash, err := definitionHash(req.RepoRoot) + if err != nil { + return AssembleResult{}, err + } + m := Manifest{ + Type: ManifestType, SchemaVersion: SchemaVersion, ContextStamp: stamp, Run: req.Run, + DefinitionSHA256: defHash, ContextSHA256: sha256Hex(contextRaw), + Supplied: SuppliedHashes{DispositionsSHA256: sha256Hex([]byte(supplied))}, + Items: make([]ManifestItem, 0, len(entries)), + AllowList: AllowList(), Exclusions: Exclusions(), + } + for _, e := range entries { + m.Items = append(m.Items, ManifestItem{Path: e.Path, Bytes: len(e.Text), SHA256: sha256Hex([]byte(e.Text))}) + } + manifestRaw, err := encode(m) + if err != nil { + return AssembleResult{}, err + } + + res := AssembleResult{ + Run: req.Run, ContextStamp: stamp, ContextSHA256: m.ContextSHA256, ItemCount: len(entries), + AllowList: m.AllowList, Artefacts: []string{}, Context: ctx, Manifest: m, + } + outDir := req.OutDir + switch { + case outDir != "": + case req.DryRun: + return res, nil + default: + outDir = DefaultRunDir + "/" + req.Run + } + res.OutDir = outDir + dir := outDir + if !filepath.IsAbs(dir) { + dir = filepath.Join(req.RepoRoot, filepath.FromSlash(outDir)) + } + if label == "" { + label = outDir + } + if err := writePair(dir, label, contextRaw, manifestRaw); err != nil { + return AssembleResult{}, err + } + res.Written = true + res.Artefacts = []string{ContextFileName, ManifestFileName} + return res, nil +} + +// requireCommittedRun refuses a run id that is not one, and a run with no +// commit marker: the scribe transcribes dispositions against a reading the +// ledger already holds, so its items come from the store and never from a raw +// output handed over a second time (adr-2609021016275803). +func requireCommittedRun(repoRoot, run string) error { + if !recordid.ValidReadingRunID(run) { + return fmt.Errorf("scribe: run %q is not a reading run id (rdg-N)", echo(run)) + } + root, err := os.OpenRoot(repoRoot) + if err != nil { + return fmt.Errorf("scribe: opening the repository root: %w", err) + } + defer root.Close() + marker := issueschema.ReadingsRecordDir + "/" + run + "/" + issueschema.RunRecordFileName + fi, err := root.Lstat(marker) + switch { + case os.IsNotExist(err): + return fmt.Errorf("scribe: %s has no commit marker at %s, so it was never ingested; the scribe "+ + "transcribes a reading the ledger already holds — ingest the run first", run, marker) + case err != nil: + return fmt.Errorf("scribe: probing %s: %w", marker, err) + case !fi.Mode().IsRegular(): + return fmt.Errorf("scribe: %s is not a regular file, so it marks nothing", marker) + } + return nil +} + +// collectLedger walks the allow list and nothing else, returning every ledger +// record sorted by path. A symlinked directory or leaf is refused, a hidden +// entry (a lock, a placeholder) is skipped, and each record is read behind the +// guarded reader at the ledger's record limit. +func collectLedger(repoRoot string) ([]LedgerEntry, error) { + root, err := os.OpenRoot(repoRoot) + if err != nil { + return nil, fmt.Errorf("scribe: opening the repository root: %w", err) + } + defer root.Close() + + var out []LedgerEntry + for _, dir := range AllowList() { + fi, err := root.Lstat(dir) + if os.IsNotExist(err) { + continue // a family with no records yet contributes nothing + } + if err != nil { + return nil, fmt.Errorf("scribe: probing %s: %w", dir, err) + } + if fi.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("%w: %s is a symlink; the context is drawn from the ledger's own "+ + "directories and a link is a route out of them", ErrSymlink, dir) + } + if !fi.IsDir() { + return nil, fmt.Errorf("scribe: %s is not a directory", dir) + } + err = fs.WalkDir(root.FS(), dir, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if p != dir && strings.HasPrefix(d.Name(), ".") { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + if d.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("%w: %s is a symlink; the context is drawn from the ledger's own files "+ + "and a link is a route out of them", ErrSymlink, p) + } + if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") { + return nil + } + raw, err := fsutil.ReadGuardedInRoot(root, p, issueschema.RecordReadLimit) + if err != nil { + if errors.Is(err, fsutil.ErrNotRegular) { + return fmt.Errorf("%w: %s is not a regular file", ErrSymlink, p) + } + return fmt.Errorf("scribe: reading %s: %w", p, err) + } + out = append(out, LedgerEntry{Path: p, Text: scrub(repoRoot, string(raw))}) + return nil + }) + if err != nil { + return nil, err + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out, nil +} + +// assertAllowList is the fail-closed half: every item must sit strictly inside +// one allow-list directory, whatever route it arrived by, or the assembly is +// refused naming it (adr-56). The collector's walk is the positive half; this is +// what makes an item that reached the context by any future route a refusal +// rather than a disclosure. +func assertAllowList(entries []LedgerEntry) error { + allowed := AllowList() + for _, e := range entries { + clean := path.Clean(e.Path) + ok := clean == e.Path && !strings.Contains(e.Path, "..") + if ok { + ok = false + for _, dir := range allowed { + if strings.HasPrefix(clean, dir+"/") { + ok = true + break + } + } + } + if !ok { + return fmt.Errorf("scribe: %s is outside the scribe's allow list (%s); the scribe receives "+ + "ledger content and nothing else, so the assembly is refused rather than disclosing it", + echo(e.Path), strings.Join(allowed, ", ")) + } + } + return nil +} + +// holdsRun reports whether the ledger entries include a reading record of run. +func holdsRun(entries []LedgerEntry, run string) bool { + prefix := runRecordsDir(run) + "/" + for _, e := range entries { + if strings.HasPrefix(e.Path, prefix) { + return true + } + } + return false +} + +// runRecordsDir is where the ledger files one run's reading records. +func runRecordsDir(run string) string { + return capture.LedgerRelPath + "/" + issueschema.ReadingsDir + "/" + run +} + +// scrub takes the caller's home and the repository root out of a text, so the +// context names no absolute local path. The ledger is redacted at capture; the +// supplied text is the researcher's own and has been through nothing. +func scrub(repoRoot, s string) string { + if abs, err := filepath.Abs(repoRoot); err == nil { + s = fsutil.RedactRoot(s, abs, ".") + if real, err := filepath.EvalSymlinks(abs); err == nil && real != abs { + s = fsutil.RedactRoot(s, real, ".") + } + } + return fsutil.RedactHome(s) +} + +// definitionHash is the sha256 of the scribe definition, read through the +// repository root so a symlinked ancestor cannot have it hash a file outside +// the repository. +func definitionHash(repoRoot string) (string, error) { + root, err := os.OpenRoot(repoRoot) + if err != nil { + return "", fmt.Errorf("scribe: opening the repository root: %w", err) + } + defer root.Close() + raw, err := fsutil.ReadGuardedInRoot(root, DefinitionPath, reading.MaxFileBytes) + if err != nil { + return "", fmt.Errorf("scribe: the definition at %s: %w", DefinitionPath, err) + } + return sha256Hex(raw), nil +} + +// writePair writes the context and the manifest into an empty or absent +// directory, both or neither, on the reading assembler's rules. +func writePair(dir, label string, contextRaw, manifestRaw []byte) error { + entries, err := os.ReadDir(dir) + switch { + case os.IsNotExist(err): + case err != nil: + return fmt.Errorf("scribe: reading the output directory: %w", err) + case len(entries) > 0: + return fmt.Errorf("scribe: the output directory %s is not empty (%d entr(y|ies)); one scribe "+ + "session's artefacts are one session's evidence, so ingest or clear the session parked there, "+ + "or name an empty directory", label, len(entries)) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("scribe: creating the output directory: %w", err) + } + ctxPath := filepath.Join(dir, ContextFileName) + if err := fsutil.WriteFileAtomic(ctxPath, contextRaw, 0o644); err != nil { + return fmt.Errorf("scribe: writing the context: %w", err) + } + if err := fsutil.WriteFileAtomic(filepath.Join(dir, ManifestFileName), manifestRaw, 0o644); err != nil { + os.Remove(ctxPath) + return fmt.Errorf("scribe: writing the manifest: %w", err) + } + return nil +} diff --git a/internal/core/scribe/assemble_test.go b/internal/core/scribe/assemble_test.go new file mode 100644 index 000000000..db3265a61 --- /dev/null +++ b/internal/core/scribe/assemble_test.go @@ -0,0 +1,312 @@ +package scribe + +import ( + "errors" + "os" + "path" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/sessionkind" +) + +const suppliedText = "Dispositions for this run, in the researcher's words.\n" + +func assembleFixture(t *testing.T, f fixture, text string) AssembleResult { + t.Helper() + res, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: fixtureRun, DispositionsPath: supply(t, text)}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + return res +} + +func readParked(t *testing.T, f fixture, name string) []byte { + t.Helper() + raw, err := os.ReadFile(filepath.Join(f.repo, filepath.FromSlash(DefaultRunDir), fixtureRun, name)) + if err != nil { + t.Fatalf("read the parked %s: %v", name, err) + } + return raw +} + +// TestScribeContextIsLedgerAndSuppliedTextOnly is ac-1's exclusion half: the +// context holds ledger records and the supplied text, and no material from the +// shipped tree, the durable record outside the ledger, the local tier or the +// transcript store — each planted with a sentinel that names its class. +func TestScribeContextIsLedgerAndSuppliedTextOnly(t *testing.T) { + f := newFixture(t, positionDetection, 2) + res := assembleFixture(t, f, suppliedText) + raw := readParked(t, f, ContextFileName) + + for rel, sentinel := range outsideSentinels { + if strings.Contains(string(raw), sentinel) { + t.Errorf("the scribe context carries %s, planted at %s", sentinel, rel) + } + } + if strings.Contains(string(raw), "SENTINEL-TRANSCRIPT-STORE") { + t.Error("the scribe context carries the transcript store's sentinel") + } + if len(res.Context.Ledger) == 0 { + t.Fatal("the context carries no ledger record, so the exclusion half asserts nothing") + } + for _, e := range res.Context.Ledger { + if !strings.HasPrefix(e.Path, capture.LedgerRelPath+"/") { + t.Errorf("the context carries %s, outside the ledger", e.Path) + } + } + if res.Context.Supplied.Dispositions != suppliedText { + t.Errorf("the supplied text is %q, want it verbatim", res.Context.Supplied.Dispositions) + } + // The open issue is the ledger's other content, and it travels. + if !contextHasPath(res.Context, ".abcd/work/issues/open/iss-1-an-open-issue.md") { + t.Error("the context omits the issue ledger's open record") + } +} + +func contextHasPath(c Context, p string) bool { + for _, e := range c.Ledger { + if e.Path == p { + return true + } + } + return false +} + +// TestScribeContextCarriesTheRunsRecordsFromTheStore: the run's reading records +// come from the store, byte for byte, and not from a raw output supplied again. +func TestScribeContextCarriesTheRunsRecordsFromTheStore(t *testing.T) { + f := newFixture(t, positionDetection, 2) + res := assembleFixture(t, f, suppliedText) + for _, item := range f.items { + rel := path.Join(capture.LedgerRelPath, issueschema.ReadingsDir, fixtureRun, item+".md") + want, err := os.ReadFile(filepath.Join(f.repo, filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + found := false + for _, e := range res.Context.Ledger { + if e.Path == rel { + found = true + if e.Text != string(want) { + t.Errorf("%s travels as %q, not as the store holds it", rel, e.Text) + } + } + } + if !found { + t.Errorf("the context omits the run's record %s", rel) + } + } +} + +// TestScribeManifestNamesEveryPathPassed is ac-1's manifest half. +func TestScribeManifestNamesEveryPathPassed(t *testing.T) { + f := newFixture(t, positionDetection, 2) + res := assembleFixture(t, f, suppliedText) + contextRaw := readParked(t, f, ContextFileName) + m, err := DecodeManifest(readParked(t, f, ManifestFileName)) + if err != nil { + t.Fatalf("the parked manifest does not decode: %v", err) + } + if len(m.Items) != len(res.Context.Ledger) { + t.Fatalf("the manifest names %d items and the context carries %d", len(m.Items), len(res.Context.Ledger)) + } + for i, e := range res.Context.Ledger { + it := m.Items[i] + if it.Path != e.Path || it.Bytes != len(e.Text) || it.SHA256 != sha([]byte(e.Text)) { + t.Errorf("manifest item %d is %+v, and the context passed %s (%d bytes)", i, it, e.Path, len(e.Text)) + } + } + if m.ContextSHA256 != sha(contextRaw) || res.ContextSHA256 != m.ContextSHA256 { + t.Errorf("the manifest's context hash %s is not the parked context's %s", m.ContextSHA256, sha(contextRaw)) + } + def, _ := os.ReadFile(filepath.Join(f.repo, DefinitionPath)) + if m.DefinitionSHA256 != sha(def) { + t.Errorf("the manifest's definition hash %s is not %s's", m.DefinitionSHA256, DefinitionPath) + } + if m.Supplied.DispositionsSHA256 != sha([]byte(suppliedText)) { + t.Errorf("the manifest's supplied hash %s does not cover the supplied text", m.Supplied.DispositionsSHA256) + } + if !reflect.DeepEqual(m.AllowList, AllowList()) { + t.Errorf("the manifest's allow list %q is not AllowList() %q", m.AllowList, AllowList()) + } + if len(m.Exclusions) == 0 { + t.Error("the manifest asserts no exclusion") + } + if m.Run != fixtureRun || m.ContextStamp != res.Context.ContextStamp { + t.Errorf("the manifest names run %s stamped %s", m.Run, m.ContextStamp) + } +} + +// TestAssertAllowListFailsClosed: an item that reached the collection by any +// route other than the allow list is refused, and nothing is written. +func TestAssertAllowListFailsClosed(t *testing.T) { + f := newFixture(t, positionDetection, 1) + restore := collectHook + t.Cleanup(func() { collectHook = restore }) + collectHook = func(repoRoot string) ([]LedgerEntry, error) { + got, err := restore(repoRoot) + return append(got, LedgerEntry{Path: ".abcd/development/brief/01-x.md", Text: "SENTINEL-BRIEF"}), err + } + _, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: fixtureRun, DispositionsPath: supply(t, suppliedText)}) + if err == nil || !strings.Contains(err.Error(), ".abcd/development/brief/01-x.md") { + t.Fatalf("an item outside the allow list was not refused by name: %v", err) + } + if _, statErr := os.Stat(filepath.Join(f.repo, filepath.FromSlash(DefaultRunDir))); !os.IsNotExist(statErr) { + t.Fatalf("a refused assembly wrote its run directory (%v)", statErr) + } + // A prefix that is a SIBLING of an allowed directory is outside it too. + if err := assertAllowList([]LedgerEntry{{Path: ".abcd/work/issues/openness/x.md"}}); err == nil { + t.Error("a sibling of an allowed directory that shares its prefix passed the assertion") + } +} + +// TestAllowListIsDerivedFromLedgerDirs: the scribe's world is the ledger's own +// directory list, so a family the ledger declares later is in it the day its +// constant is. +func TestAllowListIsDerivedFromLedgerDirs(t *testing.T) { + dirs := issueschema.LedgerDirs() + got := AllowList() + if len(got) != len(dirs) { + t.Fatalf("AllowList() = %q, LedgerDirs() = %q", got, dirs) + } + for i, d := range dirs { + if want := capture.LedgerRelPath + "/" + d; got[i] != want { + t.Errorf("AllowList()[%d] = %q, want %q", i, got[i], want) + } + } +} + +// TestScribeAssembleRefusesAnUncommittedRun: a run with no commit marker never +// happened, and a malformed run id is refused before any path is built from it. +func TestScribeAssembleRefusesAnUncommittedRun(t *testing.T) { + f := newFixture(t, positionDetection, 1) + if err := os.Remove(filepath.Join(f.repo, filepath.FromSlash(issueschema.ReadingsRecordDir), fixtureRun, + issueschema.RunRecordFileName)); err != nil { + t.Fatal(err) + } + _, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: fixtureRun, DispositionsPath: supply(t, suppliedText)}) + if err == nil || !strings.Contains(err.Error(), fixtureRun) || !strings.Contains(err.Error(), "run.json") { + t.Fatalf("an uncommitted run was not refused by name: %v", err) + } + for _, bad := range []string{"", "rdg-../x", "rdi-1", "rdg-1/../../etc"} { + if _, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: bad, DispositionsPath: supply(t, suppliedText)}); err == nil { + t.Errorf("run %q was not refused", bad) + } + } + if _, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: fixtureRun}); err == nil { + t.Error("an assembly with no supplied dispositions was not refused") + } +} + +// TestScribeContextCarriesThePerRunStamp is the scribe half of +// adr-2609021016275803. +func TestScribeContextCarriesThePerRunStamp(t *testing.T) { + f := newFixture(t, positionDetection, 2) + res := assembleFixture(t, f, suppliedText) + p, ok := sessionkind.Parse(res.Context.ContextStamp) + if !ok || p.Kind != sessionkind.Scribe || p.Run != fixtureRun { + t.Fatalf("the context is stamped %q, want the scribe stamp of %s", res.Context.ContextStamp, fixtureRun) + } + ledger, err := encode(res.Context.Ledger) + if err != nil { + t.Fatal(err) + } + if want := sha(ledger)[:sessionkind.DigestLen]; p.Digest != want { + t.Errorf("the stamp's digest is %s and the ledger array hashes to %s", p.Digest, want) + } + if found := sessionkind.Find(readParked(t, f, ContextFileName)); len(found) != 1 { + t.Errorf("the parked context carries the stamps %q, want exactly its own", found) + } +} + +// TestScribeAssembleWritesNothingBesideTheRun: assembly parks in the local tier +// and touches nothing in the durable record or the ledger, so an assembled and +// never ingested session leaves no trace beside the run. +func TestScribeAssembleWritesNothingBesideTheRun(t *testing.T) { + f := newFixture(t, positionDetection, 1) + durable := treeDigest(t, filepath.Join(f.repo, ".abcd", "development")) + work := treeDigest(t, filepath.Join(f.repo, ".abcd", "work")) + assembleFixture(t, f, suppliedText) + if treeDigest(t, filepath.Join(f.repo, ".abcd", "development")) != durable { + t.Error("assembly changed the durable record") + } + if treeDigest(t, filepath.Join(f.repo, ".abcd", "work")) != work { + t.Error("assembly changed the working tier") + } + entries, err := os.ReadDir(filepath.Join(f.repo, filepath.FromSlash(DefaultRunDir), fixtureRun)) + if err != nil || len(entries) != 2 { + t.Fatalf("the parked run directory holds %v (%v), want the context and the manifest", entries, err) + } + + // A dry run writes nothing at all, and a second assembly into the occupied + // default directory is refused rather than mixing two sessions' evidence. + dry, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: fixtureRun, + DispositionsPath: supply(t, suppliedText), DryRun: true}) + if err != nil || dry.Written { + t.Fatalf("dry run: %+v, %v", dry, err) + } + if _, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: fixtureRun, + DispositionsPath: supply(t, suppliedText)}); err == nil || !strings.Contains(err.Error(), "not empty") { + t.Fatalf("a second assembly into an occupied directory: %v", err) + } +} + +// TestScribeAssembleRefusesAnOutDirAReadingCanReach: a context parked where the +// reading include table reaches it would hand the next reading the ledger. +func TestScribeAssembleRefusesAnOutDirAReadingCanReach(t *testing.T) { + f := newFixture(t, positionDetection, 1) + _, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: fixtureRun, + DispositionsPath: supply(t, suppliedText), OutDir: "docs/scribe", OutDirLabel: "docs/scribe"}) + if err == nil || !strings.Contains(err.Error(), "docs/scribe") { + t.Fatalf("an out dir the include table reaches was not refused: %v", err) + } + out := filepath.Join(t.TempDir(), "session") + res, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: fixtureRun, + DispositionsPath: supply(t, suppliedText), OutDir: out}) + if err != nil || !res.Written { + t.Fatalf("an out dir outside the repository: %+v, %v", res, err) + } + if _, err := os.Stat(filepath.Join(out, ContextFileName)); err != nil { + t.Fatalf("the context did not land under --out: %v", err) + } +} + +// TestScribeContextCarriesNoHomePath: the supplied text is the researcher's own +// and may name their home; the context names none. +func TestScribeContextCarriesNoHomePath(t *testing.T) { + f := newFixture(t, positionDetection, 1) + assembleFixture(t, f, "See "+f.home+"/notes/run.md and "+f.repo+"/x.md for what I meant.\n") + raw := string(readParked(t, f, ContextFileName)) + for _, leak := range []string{f.home, f.repo} { + if strings.Contains(raw, leak) { + t.Errorf("the context names the absolute path %s", leak) + } + } + if !strings.Contains(raw, "~/notes/run.md") { + t.Errorf("the home path was dropped rather than redacted to ~:\n%s", raw) + } +} + +// TestScribeAssembleRefusesASymlinkedLedgerDirectory: a link inside the allow +// list is a route out of it, refused as capture's own readers refuse one. +func TestScribeAssembleRefusesASymlinkedLedgerDirectory(t *testing.T) { + f := newFixture(t, positionDetection, 1) + outside := t.TempDir() + writeFile(t, outside, "leak.md", "SENTINEL-OUTSIDE-THE-REPO\n") + link := filepath.Join(f.repo, filepath.FromSlash(capture.LedgerRelPath), issueschema.SurprisesDir) + if err := os.RemoveAll(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + _, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: fixtureRun, DispositionsPath: supply(t, suppliedText)}) + if err == nil || !errors.Is(err, ErrSymlink) { + t.Fatalf("a symlinked ledger directory was not refused: %v", err) + } +} diff --git a/internal/core/scribe/fixture_test.go b/internal/core/scribe/fixture_test.go new file mode 100644 index 000000000..2f2ea2650 --- /dev/null +++ b/internal/core/scribe/fixture_test.go @@ -0,0 +1,144 @@ +package scribe + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/issueschema" +) + +// positionDetection is the registrative position, whose items meet no ordering +// gate: the fixture a test uses when it is not about the gate. +const positionDetection = "detection" + +// fixtureRun is the run every fixture ingests; otherRun is a second run whose +// records sit beside it in the store. +const ( + fixtureRun = "rdg-2609250000000001" + otherRun = "rdg-2609250000000002" +) + +// Sentinels planted OUTSIDE the ledger. None may reach a scribe context: each +// names the class of material it stands for, so a leak names itself. +var outsideSentinels = map[string]string{ + "internal/core/thing.go": "SENTINEL-SHIPPED-SOURCE", + "docs/guide.md": "SENTINEL-SHIPPED-DOCS", + "commands/scribe.md": "SENTINEL-COMMAND-SURFACE", + ".abcd/development/brief/01-x.md": "SENTINEL-BRIEF", + ".abcd/development/intents/planned/itd-1-x.md": "SENTINEL-INTENT", + ".abcd/development/specs/open/spc-1-x.md": "SENTINEL-SPEC", + ".abcd/development/decisions/adrs/0001-x.md": "SENTINEL-DECISION", + ".abcd/work/DECISIONS.md": "SENTINEL-WORKING-DECISIONS", + ".abcd/.work.local/NEXT.md": "SENTINEL-LOCAL-TIER", + ".abcd/.work.local/transcripts/aaaa/records/x.md": "SENTINEL-LOCAL-TRANSCRIPT", +} + +// fixture is one repository holding an ingested run of n items at position, a +// second run, an open issue, the scribe definition, and every sentinel above. +type fixture struct { + repo string + home string + items []string + other []string +} + +func newFixture(t *testing.T, position string, n int) fixture { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + repo := t.TempDir() + f := fixture{repo: repo, home: home} + f.items = ingestRun(t, repo, fixtureRun, position, n) + f.other = ingestRun(t, repo, otherRun, positionDetection, 1) + for _, run := range []string{fixtureRun, otherRun} { + writeFile(t, repo, filepath.Join(issueschema.ReadingsRecordDir, run, issueschema.RunRecordFileName), + `{"run_id":"`+run+`","position":"`+position+`"}`) + } + writeFile(t, repo, ".abcd/work/issues/open/iss-1-an-open-issue.md", "---\nid: iss-1\n---\nAn open issue.\n") + writeFile(t, repo, DefinitionPath, "---\nname: scribe\nprompt_version: 0.2.0\n---\n\nThe scribe.\n") + for rel, sentinel := range outsideSentinels { + writeFile(t, repo, rel, sentinel+"\n") + } + writeFile(t, home, ".abcd/transcripts/aaaa/records/y.md", "SENTINEL-TRANSCRIPT-STORE\n") + return f +} + +// ingestRun writes n reading records for run through the ledger's own writer. +func ingestRun(t *testing.T, repo, run, position string, n int) []string { + t.Helper() + items := make([]capture.ReadingItem, 0, n) + for i := 0; i < n; i++ { + body := map[string]string{} + for _, field := range issueschema.ReadingBodyFields[position] { + body[field] = "text for " + field + } + items = append(items, capture.ReadingItem{Pattern: "a stated constraint", Body: body}) + } + res, err := capture.IngestReading(capture.IngestReadingRequest{ + RepoRoot: repo, Run: run, Manifest: "sha256:" + strings.Repeat("a", 64), + Position: position, Regime: issueschema.ReadingRegime(position), Items: items, + }) + if err != nil { + t.Fatalf("IngestReading %s: %v", run, err) + } + ids := make([]string, 0, len(res.Records)) + for _, r := range res.Records { + ids = append(ids, r.ID) + } + return ids +} + +func writeFile(t *testing.T, root, rel, content 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(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// supply writes the researcher's dispositions text outside the repository and +// returns its path. +func supply(t *testing.T, text string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "dispositions.md") + if err := os.WriteFile(p, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +// treeDigest hashes every file under dir, keyed by relative path, so a test can +// prove a tier was left byte-identical. +func treeDigest(t *testing.T, dir string) string { + t.Helper() + var lines []string + _ = filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error { + if err != nil || fi.IsDir() { + return nil + } + raw, rerr := os.ReadFile(p) + if rerr != nil { + t.Fatalf("read %s: %v", p, rerr) + } + sum := sha256.Sum256(raw) + rel, _ := filepath.Rel(dir, p) + lines = append(lines, filepath.ToSlash(rel)+" "+hex.EncodeToString(sum[:])) + return nil + }) + sort.Strings(lines) + return strings.Join(lines, "\n") +} + +func sha(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/core/scribe/ingest.go b/internal/core/scribe/ingest.go new file mode 100644 index 000000000..0c3ee96ea --- /dev/null +++ b/internal/core/scribe/ingest.go @@ -0,0 +1,579 @@ +package scribe + +// ingest.go is the scribe's OUTPUT contract, on the idiom `reading ingest` +// carries: the scribe emits JSON, this verb validates it, and the capture verbs +// write the records. +// +// This is a trust boundary. The payload is an agent's output, read behind the +// guarded reader with a byte cap, decoded strictly with every key checked +// against the closed shapes, and no payload string is joined into a path before +// its grammar is checked: the run id is matched against recordid first. Every +// payload string quoted into a message is neutralised and capped. +// +// The one validation this verb adds is the one only it can make: that the scribe +// AUTHORED NOTHING. The scribe reformats; it never adds a word. So every item it +// answers must be named in the researcher's supplied text, and every ground, +// exit condition and surprise it carries must stand there verbatim once +// whitespace is folded. Everything else — the state vocabulary, the substance +// floor, the ordering gate, the one-ground rule, redaction — is the capture +// verbs' own, inherited whole by calling them. + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/reading" + "github.com/intentdriven/abcd/internal/core/recordid" + "github.com/intentdriven/abcd/internal/fsutil" +) + +// IngestRequest is one scribe ingest. +type IngestRequest struct { + RepoRoot string + // ScribeJSONPath is the payload the scribe returned. + ScribeJSONPath string + // ContextPath is the context the session was handed; empty means the local- + // tier default for the payload's run. The manifest is read from beside it. + ContextPath string +} + +// OutDisposition is one disposition the scribe transcribed. +type OutDisposition struct { + Item string `json:"item"` + State string `json:"state"` + Grounds string `json:"grounds"` + ExitCondition string `json:"exit_condition"` + Supersedes string `json:"supersedes"` + Recurs []string `json:"recurs"` +} + +// OutAdmission is one admission the scribe transcribed. +type OutAdmission struct { + Item string `json:"item"` + Grounds string `json:"grounds"` +} + +// OutSurprise is one surprise the scribe transcribed. +type OutSurprise struct { + OccasionedBy string `json:"occasioned_by"` + Text string `json:"text"` +} + +// FidelityFlag names two pieces of material that disagree, and stops there. +type FidelityFlag struct { + First string `json:"first"` + Second string `json:"second"` +} + +// Refusal is something the scribe refused, with its reason. +type Refusal struct { + Subject string `json:"subject"` + Reason string `json:"reason"` +} + +// Output is `abcd.scribe.output/1`, the scribe's whole return. +type Output struct { + Type string `json:"_type"` + Run string `json:"run"` + ContextSHA256 string `json:"context_sha256"` + Dispositions []OutDisposition `json:"dispositions"` + Admissions []OutAdmission `json:"admissions"` + Surprises []OutSurprise `json:"surprises"` + FidelityFlags []FidelityFlag `json:"fidelity_flags"` + Outstanding []string `json:"outstanding"` + Refusals []Refusal `json:"refusals"` +} + +// The closed key sets, per level. A key outside them is a field the scribe may +// not author, refused by name. +var ( + topKeys = keySet("_type", "run", "context_sha256", "dispositions", "admissions", "surprises", "fidelity_flags", "outstanding", "refusals") + dispositionKeys = keySet("item", "state", "grounds", "exit_condition", "supersedes", "recurs") + admissionKeys = keySet("item", "grounds") + surpriseKeys = keySet("occasioned_by", "text") + flagKeys = keySet("first", "second") + refusalKeys = keySet("subject", "reason") + requiredTopKeys = []string{"_type", "run", "context_sha256"} +) + +func keySet(keys ...string) map[string]bool { + out := make(map[string]bool, len(keys)) + for _, k := range keys { + out[k] = true + } + return out +} + +// IngestResult is what one ingest landed and what it carries back unresolved. +type IngestResult struct { + Run string `json:"run"` + ContextSHA256 string `json:"context_sha256"` + Dispositions []capture.DispositionResult `json:"dispositions"` + Admissions []capture.AdmitResult `json:"admissions"` + Surprises []capture.SurpriseResult `json:"surprises"` + Outstanding []string `json:"outstanding"` + // FidelityFlags and Refusals are carried to the researcher UNRESOLVED and + // never into a record, which is what the definition promises. + FidelityFlags []FidelityFlag `json:"fidelity_flags"` + Refusals []Refusal `json:"refusals"` + // Manifest is the promoted manifest's repository-relative path, set only + // once every write has landed. + Manifest string `json:"manifest,omitempty"` +} + +// Landed names every record this ingest wrote, in order. +func (r IngestResult) Landed() []string { + var out []string + for _, d := range r.Dispositions { + out = append(out, d.ID) + } + for _, a := range r.Admissions { + if a.DispositionWritten { + out = append(out, a.Disposition) + } + out = append(out, a.Admission) + } + for _, s := range r.Surprises { + out = append(out, s.ID) + } + return out +} + +// Ingest validates one scribe payload and writes what it transcribed. +func Ingest(req IngestRequest) (IngestResult, error) { + if strings.TrimSpace(req.RepoRoot) == "" { + return IngestResult{}, errors.New("scribe: no repository root given") + } + if strings.TrimSpace(req.ScribeJSONPath) == "" { + return IngestResult{}, errors.New("scribe: no scribe output named") + } + raw, err := fsutil.ReadGuarded(req.ScribeJSONPath, reading.MaxFileBytes) + if err != nil { + return IngestResult{}, fmt.Errorf("scribe: reading the scribe output: %w", err) + } + out, err := decodeOutput(raw) + if err != nil { + return IngestResult{}, err + } + if out.Type != OutputType { + return IngestResult{}, fmt.Errorf("scribe: the output's _type is %q, want %q", echo(out.Type), OutputType) + } + if !recordid.ValidReadingRunID(out.Run) { + return IngestResult{}, fmt.Errorf("scribe: the output names run %q, which is not a reading run id (rdg-N)", echo(out.Run)) + } + + // The run's identity, proven before anything is written: the context on + // disk must hash to the parked manifest's context hash, and the payload must + // cite that same hash. + ctx, m, err := proveContext(req, out) + if err != nil { + return IngestResult{}, err + } + if err := requireCommittedRun(req.RepoRoot, out.Run); err != nil { + return IngestResult{}, err + } + if err := refusePromoted(req.RepoRoot, out.Run); err != nil { + return IngestResult{}, err + } + items, err := runItems(req.RepoRoot, out.Run) + if err != nil { + return IngestResult{}, err + } + if err := refuseAuthored(out, ctx.Supplied.Dispositions, items); err != nil { + return IngestResult{}, err + } + + res := IngestResult{ + Run: out.Run, ContextSHA256: m.ContextSHA256, + Dispositions: []capture.DispositionResult{}, Admissions: []capture.AdmitResult{}, + Surprises: []capture.SurpriseResult{}, Outstanding: nonNil(out.Outstanding), + FidelityFlags: nonNilFlags(out.FidelityFlags), Refusals: nonNilRefusals(out.Refusals), + } + + // The writes, through the verbs' own functions, in payload order. Each takes + // the ledger lock for itself and applies its own redaction and refusals; the + // first refusal stops the ingest, and the error names what landed before it. + // + // A ground and an exit condition are frontmatter scalars, which hold one + // line, so they are handed over whitespace-folded: the same folding the + // verbatim check reads them under, and a change of layout, never of words. + for i, d := range out.Dispositions { + r, err := capture.Disposition(capture.DispositionRequest{ + RepoRoot: req.RepoRoot, Item: d.Item, State: d.State, Grounds: fold(d.Grounds), + ExitCondition: fold(d.ExitCondition), Supersedes: d.Supersedes, Recurs: d.Recurs, + }) + if err != nil { + return res, stopped(res, fmt.Sprintf("dispositions[%d] (%s)", i, d.Item), err) + } + res.Dispositions = append(res.Dispositions, r) + } + for i, a := range out.Admissions { + r, err := capture.Admit(capture.AdmitRequest{RepoRoot: req.RepoRoot, Item: a.Item, Grounds: fold(a.Grounds)}) + if err != nil { + return res, stopped(res, fmt.Sprintf("admissions[%d] (%s)", i, a.Item), err) + } + res.Admissions = append(res.Admissions, r) + } + for i, s := range out.Surprises { + r, err := capture.Surprise(capture.SurpriseRequest{RepoRoot: req.RepoRoot, OccasionedBy: s.OccasionedBy, Text: s.Text}) + if err != nil { + return res, stopped(res, fmt.Sprintf("surprises[%d] (%s)", i, s.OccasionedBy), err) + } + res.Surprises = append(res.Surprises, r) + } + + // Promotion comes LAST, so a refused ingest leaves the manifest parked and a + // rerun re-proves the same context. The run directory is denied to every + // assembly by the exclusion floor, so the next reading cannot see it. + rel, err := reading.WriteRunArtefact(req.RepoRoot, out.Run, ManifestFileName, m) + if err != nil { + return res, fmt.Errorf("scribe: every record landed (%s) and promoting the manifest failed: %w", + landedList(res), err) + } + res.Manifest = rel + return res, nil +} + +// stopped states a refusal from a write, and what landed before it. +func stopped(res IngestResult, at string, err error) error { + return fmt.Errorf("scribe: %s was refused, so the ingest stopped; landed before it: %s; the manifest "+ + "stays parked, and a rerun must drop what landed: %w", at, landedList(res), err) +} + +func landedList(res IngestResult) string { + if l := res.Landed(); len(l) > 0 { + return strings.Join(l, ", ") + } + return "nothing" +} + +// decodeOutput checks every key at every level against the closed shapes, then +// decodes strictly. The key walk comes first so a refusal names the field the +// scribe authored and the entry it sat on, which the decoder's own message does +// not. +func decodeOutput(raw []byte) (Output, error) { + var top map[string]json.RawMessage + if err := json.Unmarshal(raw, &top); err != nil { + return Output{}, fmt.Errorf("scribe: the output is not a JSON object: %w", err) + } + if err := refuseKeys("the output", top, topKeys); err != nil { + return Output{}, err + } + for _, k := range requiredTopKeys { + if _, ok := top[k]; !ok { + return Output{}, fmt.Errorf("scribe: the output carries no %q", k) + } + } + for list, allowed := range map[string]map[string]bool{ + "dispositions": dispositionKeys, "admissions": admissionKeys, "surprises": surpriseKeys, + "fidelity_flags": flagKeys, "refusals": refusalKeys, + } { + rawList, ok := top[list] + if !ok || string(rawList) == "null" { + continue + } + var entries []map[string]json.RawMessage + if err := json.Unmarshal(rawList, &entries); err != nil { + return Output{}, fmt.Errorf("scribe: %q is not a list of objects: %w", list, err) + } + for i, e := range entries { + where := fmt.Sprintf("%s[%d]", list, i) + var subject string + for _, k := range []string{"item", "occasioned_by", "subject"} { + if v, ok := e[k]; ok { + _ = json.Unmarshal(v, &subject) + break + } + } + if subject != "" { + where += " (" + echo(subject) + ")" + } + if err := refuseKeys(where, e, allowed); err != nil { + return Output{}, err + } + } + } + var out Output + if err := decodeStrict(raw, &out, "the scribe output"); err != nil { + return Output{}, err + } + return out, nil +} + +// refuseKeys refuses the first key, in sorted order, that is not in allowed. +func refuseKeys(where string, obj map[string]json.RawMessage, allowed map[string]bool) error { + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if !allowed[k] { + return fmt.Errorf("scribe: %s carries %q, a field the scribe may not author; the scribe transcribes "+ + "the declared shapes and authors nothing, so the payload is refused and nothing is written", + where, echo(k)) + } + } + return nil +} + +// proveContext reads the parked manifest beside the context and proves the +// context against it, and the payload against both. +func proveContext(req IngestRequest, out Output) (Context, Manifest, error) { + ctxPath := req.ContextPath + if ctxPath == "" { + ctxPath = DefaultRunDir + "/" + out.Run + "/" + ContextFileName + } + if !filepath.IsAbs(ctxPath) { + ctxPath = filepath.Join(req.RepoRoot, filepath.FromSlash(ctxPath)) + } + manifestPath := filepath.Join(filepath.Dir(ctxPath), ManifestFileName) + mRaw, err := fsutil.ReadGuarded(manifestPath, reading.MaxFileBytes) + if err != nil { + return Context{}, Manifest{}, fmt.Errorf("scribe: reading the parked manifest for %s: %w; assemble the "+ + "session first, and ingest against the context it parked", out.Run, err) + } + m, err := DecodeManifest(mRaw) + if err != nil { + return Context{}, Manifest{}, err + } + cRaw, err := fsutil.ReadGuarded(ctxPath, reading.MaxFileBytes) + if err != nil { + return Context{}, Manifest{}, fmt.Errorf("scribe: reading the context: %w", err) + } + if got := sha256Hex(cRaw); got != m.ContextSHA256 { + return Context{}, Manifest{}, fmt.Errorf("scribe: the context on disk hashes to %s and its manifest "+ + "records %s, so it is not the context the session was handed; nothing is written", got, m.ContextSHA256) + } + if out.ContextSHA256 != m.ContextSHA256 { + return Context{}, Manifest{}, fmt.Errorf("scribe: the output cites context %s and the parked context "+ + "is %s, so the output is not from this session; nothing is written", echo(out.ContextSHA256), m.ContextSHA256) + } + ctx, err := decodeContext(cRaw) + if err != nil { + return Context{}, Manifest{}, err + } + if ctx.Run != out.Run || m.Run != out.Run || ctx.ContextStamp != m.ContextStamp { + return Context{}, Manifest{}, fmt.Errorf("scribe: the output names run %s, the context %s and the "+ + "manifest %s; one session is over one run", echo(out.Run), ctx.Run, m.Run) + } + return ctx, m, nil +} + +// refusePromoted refuses a run whose manifest is already beside it: the durable +// tier is write-once, so a second session over the run would land its records +// and then fail to promote. It is refused here, before anything lands. +func refusePromoted(repoRoot, run string) error { + rel := issueschema.ReadingsRecordDir + "/" + run + "/" + ManifestFileName + root, err := os.OpenRoot(repoRoot) + if err != nil { + return fmt.Errorf("scribe: opening the repository root: %w", err) + } + defer root.Close() + switch _, err := root.Lstat(rel); { + case err == nil: + return fmt.Errorf("scribe: %s already exists, so a scribe session over %s was ingested; the durable "+ + "tier is write-once, and a later answer is written with the capture verbs", rel, run) + case !os.IsNotExist(err): + return fmt.Errorf("scribe: probing %s: %w", rel, err) + } + return nil +} + +// runItems lists the run's reading items as the store holds them, each with +// whether a disposition already stands over it. +func runItems(repoRoot, run string) (map[string]bool, error) { + dir := filepath.Join(repoRoot, filepath.FromSlash(runRecordsDir(run))) + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("scribe: listing the reading records of %s: %w", run, err) + } + items := map[string]bool{} + for _, e := range entries { + id, ok := strings.CutSuffix(e.Name(), ".md") + if !ok || !recordid.ValidReadingItemID(id) || !e.Type().IsRegular() { + continue + } + fate, err := capture.ItemFate(repoRoot, run, id) + if err != nil { + return nil, fmt.Errorf("scribe: reading the fate of %s: %w", id, err) + } + items[id] = len(fate.Dispositions) > 0 || fate.Cyclic + } + return items, nil +} + +// refuseAuthored is the authoring refusal: every item answered is the run's and +// is named in the supplied text; every free text is the researcher's own words; +// the outstanding list is the run's unanswered items; and no item of the run +// that stands unanswered is passed over in silence. +func refuseAuthored(out Output, supplied string, items map[string]bool) error { + folded := fold(supplied) + named := func(id string) bool { return mentions(supplied, id) } + verbatim := func(where, field, text string) error { + if text == "" || strings.Contains(folded, fold(text)) { + return nil + } + return fmt.Errorf("scribe: %s carries a %s the researcher did not write (%q does not stand in the "+ + "supplied dispositions); the scribe reformats and never adds a word, so the payload is refused and "+ + "nothing is written", where, field, echo(text)) + } + ofRun := func(where, id string) error { + if _, ok := items[id]; !ok { + return fmt.Errorf("scribe: %s names %s, which is not an item of %s; a scribe session transcribes "+ + "one run", where, echo(id), out.Run) + } + return nil + } + + answered := map[string]string{} + answer := func(where, id string) error { + if prior, dup := answered[id]; dup { + return fmt.Errorf("scribe: %s answers %s, which %s already answers; one item takes one answer in "+ + "one payload", where, echo(id), prior) + } + answered[id] = where + return nil + } + for i, d := range out.Dispositions { + where := fmt.Sprintf("dispositions[%d] (%s)", i, echo(d.Item)) + if err := ofRun(where, d.Item); err != nil { + return err + } + if !named(d.Item) { + return fmt.Errorf("scribe: %s is a disposition the researcher did not supply: the supplied "+ + "dispositions never name %s", where, echo(d.Item)) + } + for _, id := range append([]string{d.Supersedes}, d.Recurs...) { + if id != "" && !named(id) { + return fmt.Errorf("scribe: %s cites %s, which the supplied dispositions never name", where, echo(id)) + } + } + if err := verbatim(where, "grounds", d.Grounds); err != nil { + return err + } + if err := verbatim(where, "exit_condition", d.ExitCondition); err != nil { + return err + } + if err := answer(where, d.Item); err != nil { + return err + } + } + for i, a := range out.Admissions { + where := fmt.Sprintf("admissions[%d] (%s)", i, echo(a.Item)) + if err := ofRun(where, a.Item); err != nil { + return err + } + if !named(a.Item) { + return fmt.Errorf("scribe: %s is an admission the researcher did not supply: the supplied "+ + "dispositions never name %s", where, echo(a.Item)) + } + if err := verbatim(where, "grounds", a.Grounds); err != nil { + return err + } + // An admission and a disposition of one item are one act when their + // grounds agree, which Admit holds; a second admission is not. + if prior, dup := answered[a.Item]; dup && strings.HasPrefix(prior, "admissions") { + return fmt.Errorf("scribe: %s admits %s, which %s already admits", where, echo(a.Item), prior) + } + if _, dup := answered[a.Item]; !dup { + answered[a.Item] = where + } + } + for i, s := range out.Surprises { + where := fmt.Sprintf("surprises[%d] (%s)", i, echo(s.OccasionedBy)) + if !named(s.OccasionedBy) { + return fmt.Errorf("scribe: %s is keyed to %s, which the supplied dispositions never name", + where, echo(s.OccasionedBy)) + } + if strings.TrimSpace(s.Text) == "" { + return fmt.Errorf("scribe: %s carries no text", where) + } + if err := verbatim(where, "text", s.Text); err != nil { + return err + } + } + for i, id := range out.Outstanding { + where := fmt.Sprintf("outstanding[%d]", i) + if err := ofRun(where, id); err != nil { + return err + } + if prior, dup := answered[id]; dup { + return fmt.Errorf("scribe: %s lists %s as outstanding and %s answers it; an outstanding item is "+ + "one given no disposition", where, echo(id), prior) + } + answered[id] = where + } + for i, r := range out.Refusals { + if _, ok := items[r.Subject]; ok { + if _, dup := answered[r.Subject]; !dup { + answered[r.Subject] = fmt.Sprintf("refusals[%d]", i) + } + } + } + + // Silence. An item of the run with no standing disposition that the payload + // neither answers, lists as outstanding nor refuses is refused: an item the + // scribe says nothing about reads as one nobody raised. An item already + // answered in the ledger is not owed again, which is what lets a rerun after + // a partial ingest drop what landed. + var silent []string + for id, standing := range items { + if _, ok := answered[id]; !ok && !standing { + silent = append(silent, id) + } + } + sort.Strings(silent) + if len(silent) > 0 { + return fmt.Errorf("scribe: the output says nothing about %s of %s; silence is not one of the scribe's "+ + "options, so every item is answered, listed as outstanding, or named in a refusal", + strings.Join(silent, ", "), out.Run) + } + return nil +} + +// fold collapses every run of whitespace to one space and trims the ends, so a +// re-wrapped sentence is the same words. +func fold(s string) string { return strings.Join(strings.Fields(s), " ") } + +// mentions reports whether text names id as a whole token: rdi-12 is not named +// by a text that says rdi-123. +func mentions(text, id string) bool { + if id == "" { + return false + } + re, err := regexp.Compile(`(^|[^A-Za-z0-9-])` + regexp.QuoteMeta(id) + `($|[^A-Za-z0-9])`) + if err != nil { + return false + } + return re.MatchString(text) +} + +func nonNil(in []string) []string { + if in == nil { + return []string{} + } + return in +} + +func nonNilFlags(in []FidelityFlag) []FidelityFlag { + if in == nil { + return []FidelityFlag{} + } + return in +} + +func nonNilRefusals(in []Refusal) []Refusal { + if in == nil { + return []Refusal{} + } + return in +} diff --git a/internal/core/scribe/ingest_test.go b/internal/core/scribe/ingest_test.go new file mode 100644 index 000000000..b72eb71e4 --- /dev/null +++ b/internal/core/scribe/ingest_test.go @@ -0,0 +1,467 @@ +package scribe + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/issueschema" +) + +// A ground that clears the capture verbs' substance floor. +const groundA = "the constraint the reading names is real and binds the verb as shipped" + +// session is one assembled scribe session over a fixture: the fixture, the +// supplied text and the assembly's result. +type session struct { + fixture + supplied string + res AssembleResult +} + +// assembleSession builds a fixture of n items at position and assembles over +// the supplied text, which may name the items as {0}, {1}, ... placeholders. +func assembleSession(t *testing.T, position string, n int, supplied string) session { + t.Helper() + f := newFixture(t, position, n) + for i, id := range f.items { + supplied = strings.ReplaceAll(supplied, "{"+string(rune('0'+i))+"}", id) + } + res, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: fixtureRun, DispositionsPath: supply(t, supplied)}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + return session{fixture: f, supplied: supplied, res: res} +} + +// out is a payload skeleton for the session: the envelope filled, every list +// empty. +func (s session) out() Output { + return Output{Type: OutputType, Run: fixtureRun, ContextSHA256: s.res.ContextSHA256} +} + +// write puts a payload on disk and returns its path. +func (s session) write(t *testing.T, o any) string { + t.Helper() + raw, err := json.Marshal(o) + if err != nil { + t.Fatal(err) + } + return s.writeRaw(t, string(raw)) +} + +func (s session) writeRaw(t *testing.T, raw string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "scribe-output.json") + if err := os.WriteFile(p, []byte(raw), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func (s session) ingest(t *testing.T, payloadPath string) (IngestResult, error) { + t.Helper() + return Ingest(IngestRequest{RepoRoot: s.repo, ScribeJSONPath: payloadPath}) +} + +func (s session) ledger(t *testing.T) string { + t.Helper() + return treeDigest(t, filepath.Join(s.repo, filepath.FromSlash(capture.LedgerRelPath))) +} + +func (s session) promoted() string { + return filepath.Join(s.repo, filepath.FromSlash(issueschema.ReadingsRecordDir), fixtureRun, ManifestFileName) +} + +// dispositionFiles lists every disposition record in the ledger. +func (s session) dispositionFiles(t *testing.T) []string { + t.Helper() + var out []string + root := filepath.Join(s.repo, filepath.FromSlash(capture.LedgerRelPath), issueschema.DispositionsDir) + _ = filepath.Walk(root, func(p string, fi os.FileInfo, err error) error { + if err == nil && !fi.IsDir() { + out = append(out, p) + } + return nil + }) + return out +} + +// TestScribeIngestWritesASuppliedDisposition is ac-2: a disposition the +// researcher supplied lands through the disposition writer, and the ledger +// differs afterwards by that one record. +func TestScribeIngestWritesASuppliedDisposition(t *testing.T) { + s := assembleSession(t, positionDetection, 2, + "{0}: accepted — "+groundA+".\n{1}: I have not decided yet.\n") + before := s.dispositionFiles(t) + o := s.out() + o.Dispositions = []OutDisposition{{Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}} + o.Outstanding = []string{s.items[1]} + + res, err := s.ingest(t, s.write(t, o)) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + after := s.dispositionFiles(t) + if len(before) != 0 || len(after) != 1 || len(res.Dispositions) != 1 { + t.Fatalf("dispositions before %v, after %v, result %+v; want exactly one written", before, after, res.Dispositions) + } + if d := res.Dispositions[0]; d.Item != s.items[0] || d.State != issueschema.DispositionAccepted { + t.Fatalf("wrote %+v", d) + } + raw, _ := os.ReadFile(after[0]) + if !strings.Contains(string(raw), groundA) { + t.Fatalf("the disposition does not carry the supplied ground:\n%s", raw) + } + // Nothing else in the ledger moved: no admission, no surprise, no record for + // the outstanding item. + for _, dir := range []string{issueschema.AdmissionsDir, issueschema.SurprisesDir} { + if _, err := os.Stat(filepath.Join(s.repo, filepath.FromSlash(capture.LedgerRelPath), dir)); err == nil { + entries, _ := os.ReadDir(filepath.Join(s.repo, filepath.FromSlash(capture.LedgerRelPath), dir)) + if len(entries) != 0 { + t.Errorf("%s gained %d entries", dir, len(entries)) + } + } + } + if len(res.Outstanding) != 1 || res.Outstanding[0] != s.items[1] { + t.Errorf("outstanding = %q, want [%s]", res.Outstanding, s.items[1]) + } +} + +// TestScribeIngestRefusesAnAuthoredField is ac-3's first half: a key outside the +// closed shapes is refused by name, with the item it was on, and nothing lands. +func TestScribeIngestRefusesAnAuthoredField(t *testing.T) { + s := assembleSession(t, positionDetection, 1, "{0}: accepted — "+groundA+".\n") + before := s.ledger(t) + for _, tc := range []struct { + name, raw, field string + }{ + {"a resolution on a disposition", `{"_type":"` + OutputType + `","run":"` + fixtureRun + + `","context_sha256":"` + s.res.ContextSHA256 + `","dispositions":[{"item":"` + s.items[0] + + `","state":"accepted","grounds":"` + groundA + `","resolution":"fixed it"}]}`, "resolution"}, + {"a pattern at the top", `{"_type":"` + OutputType + `","run":"` + fixtureRun + + `","context_sha256":"` + s.res.ContextSHA256 + `","pattern":"mine","outstanding":["` + s.items[0] + `"]}`, "pattern"}, + {"a position on a fidelity flag", `{"_type":"` + OutputType + `","run":"` + fixtureRun + + `","context_sha256":"` + s.res.ContextSHA256 + `","outstanding":["` + s.items[0] + + `"],"fidelity_flags":[{"first":"a","second":"b","position":"widening"}]}`, "position"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := s.ingest(t, s.writeRaw(t, tc.raw)) + if err == nil || !strings.Contains(err.Error(), `"`+tc.field+`"`) { + t.Fatalf("an authored %q was not refused by name: %v", tc.field, err) + } + if tc.field == "resolution" && !strings.Contains(err.Error(), s.items[0]) { + t.Errorf("the refusal does not name the item it was on: %v", err) + } + if s.ledger(t) != before { + t.Fatal("a refused payload changed the ledger") + } + }) + } +} + +// TestScribeIngestRefusesAnUnsuppliedGround is ac-3's second half, on the +// disposition: a ground the researcher did not write is refused, naming the +// field and the item. +func TestScribeIngestRefusesAnUnsuppliedGround(t *testing.T) { + s := assembleSession(t, positionDetection, 1, "{0}: accepted — "+groundA+".\n") + before := s.ledger(t) + o := s.out() + o.Dispositions = []OutDisposition{{Item: s.items[0], State: issueschema.DispositionAccepted, + Grounds: groundA + " and also because the scribe thinks so"}} + _, err := s.ingest(t, s.write(t, o)) + if err == nil || !strings.Contains(err.Error(), "grounds") || !strings.Contains(err.Error(), s.items[0]) { + t.Fatalf("an unsupplied ground was not refused naming grounds and the item: %v", err) + } + if s.ledger(t) != before { + t.Fatal("a refused payload changed the ledger") + } + + // Reformatting is the scribe's licence: whitespace folds, so a ground + // re-wrapped across lines is the same words. + o.Dispositions[0].Grounds = strings.ReplaceAll(groundA, " the ", "\n the ") + if _, err := s.ingest(t, s.write(t, o)); err != nil { + t.Fatalf("a re-wrapped supplied ground was refused: %v", err) + } +} + +// TestScribeIngestRefusesAnUnsuppliedAdmissionGround: the same check on an +// admission, which is the other record carrying a ground. +func TestScribeIngestRefusesAnUnsuppliedAdmissionGround(t *testing.T) { + s := assembleSession(t, issueschema.PositionWidening, 1, "{0}: admit it — "+groundA+".\n") + before := s.ledger(t) + o := s.out() + o.Admissions = []OutAdmission{{Item: s.items[0], Grounds: "a ground nobody wrote down for this proposal"}} + _, err := s.ingest(t, s.write(t, o)) + if err == nil || !strings.Contains(err.Error(), "grounds") || !strings.Contains(err.Error(), s.items[0]) { + t.Fatalf("an unsupplied admission ground was not refused: %v", err) + } + if s.ledger(t) != before { + t.Fatal("a refused payload changed the ledger") + } +} + +// TestScribeIngestRefusesAnUnsuppliedDisposition: a disposition for an item the +// supplied text never names is one the researcher did not supply. +func TestScribeIngestRefusesAnUnsuppliedDisposition(t *testing.T) { + s := assembleSession(t, positionDetection, 2, "{0}: accepted — "+groundA+".\n") + before := s.ledger(t) + o := s.out() + o.Dispositions = []OutDisposition{ + {Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}, + {Item: s.items[1], State: issueschema.DispositionAccepted, Grounds: groundA}, + } + _, err := s.ingest(t, s.write(t, o)) + if err == nil || !strings.Contains(err.Error(), s.items[1]) { + t.Fatalf("a disposition the researcher did not supply was not refused: %v", err) + } + if s.ledger(t) != before { + t.Fatal("a refused payload changed the ledger") + } +} + +// TestScribeIngestReportsOutstandingAndWritesNothing is ac-4. +func TestScribeIngestReportsOutstandingAndWritesNothing(t *testing.T) { + s := assembleSession(t, positionDetection, 2, "Nothing decided yet.\n") + before := s.ledger(t) + o := s.out() + o.Outstanding = []string{s.items[0], s.items[1]} + res, err := s.ingest(t, s.write(t, o)) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + if len(res.Outstanding) != 2 { + t.Fatalf("outstanding = %q, want both items", res.Outstanding) + } + if s.ledger(t) != before { + t.Fatal("an all-outstanding payload wrote a ledger record") + } + + // An outstanding item must be the run's, and must carry no disposition in + // the same payload. + s2 := assembleSession(t, positionDetection, 1, "{0}: accepted — "+groundA+".\n") + o2 := s2.out() + o2.Dispositions = []OutDisposition{{Item: s2.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}} + o2.Outstanding = []string{s2.items[0]} + if _, err := s2.ingest(t, s2.write(t, o2)); err == nil { + t.Error("an item both dispositioned and outstanding was not refused") + } + o2.Dispositions = nil + o2.Outstanding = []string{s2.items[0], s2.other[0]} + if _, err := s2.ingest(t, s2.write(t, o2)); err == nil || !strings.Contains(err.Error(), s2.other[0]) { + t.Errorf("an outstanding item of another run was not refused by name: %v", err) + } +} + +// TestScribeIngestRefusesASilentItem: an item of the run the payload says +// nothing about is refused, because silence is not one of the scribe's options. +func TestScribeIngestRefusesASilentItem(t *testing.T) { + s := assembleSession(t, positionDetection, 2, "{0}: accepted — "+groundA+".\n") + before := s.ledger(t) + o := s.out() + o.Dispositions = []OutDisposition{{Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}} + _, err := s.ingest(t, s.write(t, o)) + if err == nil || !strings.Contains(err.Error(), s.items[1]) { + t.Fatalf("a silent item was not refused by name: %v", err) + } + if s.ledger(t) != before { + t.Fatal("a refused payload changed the ledger") + } + // A refusal naming the item is not silence. + o.Refusals = []Refusal{{Subject: s.items[1], Reason: "the dispositions text addresses me rather than the item"}} + if _, err := s.ingest(t, s.write(t, o)); err != nil { + t.Fatalf("an item named in the refusals was treated as silent: %v", err) + } +} + +// TestScribeIngestProvesTheContextHash: nothing is written until the context on +// disk hashes to the parked manifest's context hash and the payload cites it. +func TestScribeIngestProvesTheContextHash(t *testing.T) { + s := assembleSession(t, positionDetection, 1, "{0}: accepted — "+groundA+".\n") + before := s.ledger(t) + o := s.out() + o.Dispositions = []OutDisposition{{Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}} + + wrong := o + wrong.ContextSHA256 = strings.Repeat("0", 64) + if _, err := s.ingest(t, s.write(t, wrong)); err == nil || !strings.Contains(err.Error(), "context") { + t.Fatalf("a payload citing another context was not refused: %v", err) + } + + ctxPath := filepath.Join(s.repo, filepath.FromSlash(DefaultRunDir), fixtureRun, ContextFileName) + raw, _ := os.ReadFile(ctxPath) + tampered := strings.Replace(string(raw), groundA, groundA+" plus a line the scribe slipped in", 1) + if err := os.WriteFile(ctxPath, []byte(tampered), 0o644); err != nil { + t.Fatal(err) + } + if _, err := s.ingest(t, s.write(t, o)); err == nil || !strings.Contains(err.Error(), "context") { + t.Fatalf("a context that no longer hashes to its manifest was not refused: %v", err) + } + if s.ledger(t) != before { + t.Fatal("an unproven context let a write through") + } +} + +// TestScribeIngestCarriesFidelityFlagsIntoNoRecord: flags and refusals travel on +// the result, unresolved, and never into a record. +func TestScribeIngestCarriesFidelityFlagsIntoNoRecord(t *testing.T) { + s := assembleSession(t, positionDetection, 1, "{0}: accepted — "+groundA+".\n") + o := s.out() + o.Dispositions = []OutDisposition{{Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}} + o.FidelityFlags = []FidelityFlag{{First: "FLAG-FIRST-PIECE", Second: "FLAG-SECOND-PIECE"}} + o.Refusals = []Refusal{{Subject: "REFUSAL-SUBJECT", Reason: "REFUSAL-REASON"}} + res, err := s.ingest(t, s.write(t, o)) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + if len(res.FidelityFlags) != 1 || res.FidelityFlags[0].First != "FLAG-FIRST-PIECE" || len(res.Refusals) != 1 { + t.Fatalf("the result carries flags %+v and refusals %+v", res.FidelityFlags, res.Refusals) + } + var leaked []string + _ = filepath.Walk(filepath.Join(s.repo, ".abcd"), func(p string, fi os.FileInfo, err error) error { + if err != nil || fi.IsDir() || strings.Contains(p, ".work.local") { + return nil + } + raw, _ := os.ReadFile(p) + for _, tok := range []string{"FLAG-FIRST-PIECE", "FLAG-SECOND-PIECE", "REFUSAL-SUBJECT", "REFUSAL-REASON"} { + if strings.Contains(string(raw), tok) { + leaked = append(leaked, tok+" in "+p) + } + } + return nil + }) + if len(leaked) > 0 { + t.Fatalf("flags or refusals reached a record: %v", leaked) + } +} + +// TestScribeIngestNamesWhatLandedBeforeARefusal: a write the writer refuses +// stops the ingest, and the result names what landed before it, so a rerun sees +// the partial state rather than minting it twice. +func TestScribeIngestNamesWhatLandedBeforeARefusal(t *testing.T) { + s := assembleSession(t, positionDetection, 2, + "{0}: accepted — "+groundA+".\n{1}: accepted — "+groundA+".\n") + // The second item gains a standing disposition AFTER the context was built, + // so the writer refuses a second answer that does not cite it. + if _, err := capture.Disposition(capture.DispositionRequest{RepoRoot: s.repo, Item: s.items[1], + State: issueschema.DispositionAccepted, Grounds: groundA}); err != nil { + t.Fatal(err) + } + o := s.out() + o.Dispositions = []OutDisposition{ + {Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}, + {Item: s.items[1], State: issueschema.DispositionAccepted, Grounds: groundA}, + } + res, err := s.ingest(t, s.write(t, o)) + if err == nil { + t.Fatal("a write the writer refuses did not stop the ingest") + } + if len(res.Dispositions) != 1 || res.Dispositions[0].Item != s.items[0] { + t.Fatalf("the result names %+v as landed, want the first item's disposition", res.Dispositions) + } + if !strings.Contains(err.Error(), res.Dispositions[0].ID) { + t.Errorf("the refusal does not name what landed before it: %v", err) + } + if _, statErr := os.Stat(s.promoted()); !os.IsNotExist(statErr) { + t.Error("a refused ingest promoted the manifest") + } +} + +// TestScribeIngestRefusesBeforeTheComparativeRun: the ordering gate the shared +// writer holds refuses a widening payload at its first disposition, before +// anything lands, and names the run it is waiting for. +func TestScribeIngestRefusesBeforeTheComparativeRun(t *testing.T) { + s := assembleSession(t, issueschema.PositionWidening, 1, "{0}: accepted — "+groundA+".\n") + before := s.ledger(t) + o := s.out() + o.Dispositions = []OutDisposition{{Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}} + res, err := s.ingest(t, s.write(t, o)) + if !errors.Is(err, capture.ErrNotCharacterised) || !strings.Contains(err.Error(), fixtureRun) { + t.Fatalf("a widening payload with no comparative run: %v", err) + } + if len(res.Dispositions) != 0 || s.ledger(t) != before { + t.Fatal("something landed before the ordering gate refused") + } +} + +// TestScribeIngestPromotesTheManifestLast: a refused ingest leaves the manifest +// parked and nothing beside the run; a completed one lands it write-once. +func TestScribeIngestPromotesTheManifestLast(t *testing.T) { + s := assembleSession(t, positionDetection, 1, "{0}: accepted — "+groundA+".\n") + bad := s.out() + bad.Dispositions = []OutDisposition{{Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: "unsupplied words here"}} + if _, err := s.ingest(t, s.write(t, bad)); err == nil { + t.Fatal("expected a refusal") + } + if _, err := os.Stat(s.promoted()); !os.IsNotExist(err) { + t.Fatal("a refused ingest promoted the manifest") + } + parked := filepath.Join(s.repo, filepath.FromSlash(DefaultRunDir), fixtureRun, ManifestFileName) + if _, err := os.Stat(parked); err != nil { + t.Fatalf("a refused ingest moved the parked manifest: %v", err) + } + + good := s.out() + good.Dispositions = []OutDisposition{{Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}} + res, err := s.ingest(t, s.write(t, good)) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + promoted, err := os.ReadFile(s.promoted()) + if err != nil { + t.Fatalf("the manifest was not promoted beside the run: %v", err) + } + parkedRaw, _ := os.ReadFile(parked) + if string(promoted) != string(parkedRaw) { + t.Error("the promoted manifest is not the parked one") + } + if res.Manifest == "" { + t.Error("the result does not name the promoted manifest") + } + // Write-once: a second session over the run is refused BEFORE it writes. The + // payload carries a surprise the supplied text holds, so a refusal that came + // only at promotion would leave the surprise in the ledger. + before := s.ledger(t) + again := s.out() + again.Surprises = []OutSurprise{{OccasionedBy: s.items[0], Text: groundA}} + if _, err := s.ingest(t, s.write(t, again)); err == nil || !strings.Contains(err.Error(), ManifestFileName) { + t.Fatalf("a second ingest over a promoted run was not refused: %v", err) + } + if s.ledger(t) != before { + t.Fatal("the refused second ingest changed the ledger") + } +} + +// TestScribeIngestWritesAdmissionsAndSurprises: the other two record families +// the scribe transcribes land through their own verbs, on supplied words. +func TestScribeIngestWritesAdmissionsAndSurprises(t *testing.T) { + surprise := "I did not expect the proposal to touch the release gate at all" + s := assembleSession(t, issueschema.PositionWidening, 1, + "{0}: admit it — "+groundA+".\nSurprise at {0}: "+surprise+".\n") + // Characterise the widening run, so the ordering gate lets the admission + // through. + writeFile(t, s.repo, filepath.Join(issueschema.ReadingsRecordDir, "rdg-2609250000000009", issueschema.RunRecordFileName), + `{"run_id":"rdg-2609250000000009","position":"comparative","candidate_run":"`+fixtureRun+`"}`) + o := s.out() + o.Admissions = []OutAdmission{{Item: s.items[0], Grounds: groundA}} + o.Surprises = []OutSurprise{{OccasionedBy: s.items[0], Text: surprise}} + res, err := s.ingest(t, s.write(t, o)) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + if len(res.Admissions) != 1 || !res.Admissions[0].DispositionWritten || len(res.Surprises) != 1 { + t.Fatalf("admissions %+v, surprises %+v", res.Admissions, res.Surprises) + } + // A surprise text the researcher did not write is refused. + s2 := assembleSession(t, positionDetection, 1, "{0}: nothing yet.\n") + o2 := s2.out() + o2.Outstanding = []string{s2.items[0]} + o2.Surprises = []OutSurprise{{OccasionedBy: s2.items[0], Text: surprise}} + if _, err := s2.ingest(t, s2.write(t, o2)); err == nil || !strings.Contains(err.Error(), "text") { + t.Fatalf("an unsupplied surprise was not refused: %v", err) + } +} diff --git a/internal/core/scribe/scribe.go b/internal/core/scribe/scribe.go new file mode 100644 index 000000000..f16d0228d --- /dev/null +++ b/internal/core/scribe/scribe.go @@ -0,0 +1,257 @@ +// Package scribe builds the ledger scribe's context and ingests what the scribe +// returns (itd-2609020625402599, spc-2609020626045177). +// +// The scribe (agents/scribe.md) transcribes a returned reading's records and the +// researcher's dispositions into the ledger's declared shapes, and its access +// rule is the reading assembler's exact inverse: ledger content only, never the +// shipped repository. Until this package that rule was held by the definition +// alone. Here it is held by construction, on the reading assembler's idiom: +// +// - Assemble builds the context by POSITIVE inclusion at directory grain, from +// an allow list derived from the ledger's own directory constants +// (issueschema.LedgerDirs), plus the researcher's supplied text. Nothing +// outside the list is walked, and assertAllowList refuses any item whose path +// is outside it whatever route it arrived by. A manifest names every path +// passed, by hash, and is parked in the local tier. +// - Ingest validates the scribe's four outputs and writes dispositions, +// admissions and surprises through the capture verbs' own functions, adding +// no validation path of its own beyond the one thing only it can check: that +// the scribe authored nothing. Every ground, exit condition and surprise the +// payload carries must already stand in the supplied text. The manifest is +// promoted beside the run last, inside the read block. +// +// Both artefacts carry the per-run context stamp (core/sessionkind), so a +// retained transcript that held this context says so, and the transcript +// store's separation check can see it (adr-2609021016275803). +// +// `scribe` is its own top-level verb rather than a sub-verb of `reading`, +// because the two contexts must never share a front door. +package scribe + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/termsafe" +) + +// The three artefact type tags. They are carried in the documents so a loose +// file can be told apart without its name. +const ( + ContextType = "abcd.scribe.context/1" + ManifestType = "abcd.scribe.manifest/1" + OutputType = "abcd.scribe.output/1" +) + +// SchemaVersion is the shape version of the context and the manifest. +const SchemaVersion = 1 + +// DefaultRunDir is the local-tier directory a session's context and manifest +// are parked in, one subdirectory per reading run. The local tier is on the +// reading assembler's exclusion floor, so nothing parked here reaches a reading. +const DefaultRunDir = ".abcd/.work.local/scratch/scribe-runs" + +// The parked filenames. ManifestFileName is also the name the manifest is +// promoted under beside the run. +const ( + ContextFileName = "context.json" + ManifestFileName = "scribe-manifest.json" +) + +// DefinitionPath is the scribe definition whose hash the manifest records. +const DefinitionPath = "agents/scribe.md" + +// ErrSymlink refuses a symlinked directory or leaf inside the allow list: a link +// is a route out of the ledger that a prefix check on its name cannot see. +var ErrSymlink = errors.New("scribe: a symlink inside the ledger") + +// AllowList is every directory the scribe's context may draw from, repository +// relative: the issue ledger's own directory list under its root. It is DERIVED +// from issueschema.LedgerDirs, so a family the ledger declares later is on this +// list the day its constant is, and the reading assembler's comparative +// exclusion rows describe the same set from the same function. +func AllowList() []string { + dirs := issueschema.LedgerDirs() + out := make([]string, 0, len(dirs)) + for _, d := range dirs { + out = append(out, capture.LedgerRelPath+"/"+d) + } + return out +} + +// LedgerEntry is one ledger record as the scribe receives it. The scribe files +// by ledger path, so its material may name one; it is the reading bundle that +// may not. +type LedgerEntry struct { + Path string `json:"path"` + Text string `json:"text"` +} + +// Supplied is the researcher's own material, verbatim. +type Supplied struct { + Dispositions string `json:"dispositions"` +} + +// Context is `abcd.scribe.context/1`: the scribe session's whole working set. +type Context struct { + Type string `json:"_type"` + SchemaVersion int `json:"schema_version"` + // ContextStamp is the scribe kind's per-run stamp over the ledger array, in + // the position the reading bundle carries its own. + ContextStamp string `json:"context_stamp"` + Run string `json:"run"` + Ledger []LedgerEntry `json:"ledger"` + Supplied Supplied `json:"supplied"` +} + +// ManifestItem names one ledger record passed, by path, length and hash. +type ManifestItem struct { + Path string `json:"path"` + Bytes int `json:"bytes"` + SHA256 string `json:"sha256"` +} + +// SuppliedHashes is the manifest's account of the supplied text. +type SuppliedHashes struct { + DispositionsSHA256 string `json:"dispositions_sha256"` +} + +// Exclusion is one class of material the context refuses, and the signal that +// holds the refusal. +type Exclusion struct { + Source string `json:"source"` + Signal string `json:"signal"` +} + +// Manifest is `abcd.scribe.manifest/1`: what the assembly passed, by path and +// hash, and what it refused. It carries no record text. +type Manifest struct { + Type string `json:"_type"` + SchemaVersion int `json:"schema_version"` + ContextStamp string `json:"context_stamp"` + Run string `json:"run"` + DefinitionSHA256 string `json:"definition_sha256"` + ContextSHA256 string `json:"context_sha256"` + Supplied SuppliedHashes `json:"supplied"` + Items []ManifestItem `json:"items"` + AllowList []string `json:"allow_list"` + Exclusions []Exclusion `json:"exclusions"` +} + +// allowListSignal is the signal every exclusion row rests on: the collector +// walks nothing but the allow list, and assertAllowList refuses any item outside +// it by prefix. +const allowListSignal = "not walked: no allow-list directory lies in it, and assertAllowList refuses " + + "any item outside the allow list by path prefix" + +// Exclusions is the manifest's exclusion assertion. Each row names a class of +// material by its location, never a home path. +func Exclusions() []Exclusion { + return []Exclusion{ + {Source: "the shipped tree (every path outside .abcd/)", Signal: allowListSignal}, + {Source: ".abcd/development (the durable record: brief, intents, specs, decisions, readings)", + Signal: allowListSignal}, + {Source: ".abcd/work outside " + capture.LedgerRelPath + " (the shared working tier)", + Signal: allowListSignal}, + {Source: ".abcd/.work.local (the local tier, the per-repo transcript store included)", + Signal: allowListSignal}, + {Source: "the session-transcript store under the user's home", + Signal: "unreachable: outside the repository tree, and no walk starts outside it"}, + } +} + +// encode is the one definition of canonical bytes for the scribe's artefacts, +// the reading assembler's: struct field order, two-space indent, no HTML +// escaping, one trailing newline. +func encode(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + return nil, fmt.Errorf("scribe: encoding an artefact: %w", err) + } + return buf.Bytes(), nil +} + +// decodeStrict decodes one document, refusing unknown fields and trailing +// content. +func decodeStrict(data []byte, into any, what string) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(into); err != nil { + return fmt.Errorf("scribe: decoding %s: %w", what, err) + } + if dec.More() { + return fmt.Errorf("scribe: decoding %s: trailing content after the document", what) + } + return nil +} + +// DecodeManifest reads a scribe manifest strictly. +func DecodeManifest(data []byte) (Manifest, error) { + var m Manifest + if err := decodeStrict(data, &m, "the scribe manifest"); err != nil { + return Manifest{}, err + } + if m.Type != ManifestType || m.SchemaVersion != SchemaVersion { + return Manifest{}, fmt.Errorf("scribe: the manifest is %q version %d, want %q version %d", + echo(m.Type), m.SchemaVersion, ManifestType, SchemaVersion) + } + return m, nil +} + +// decodeContext reads a scribe context strictly. +func decodeContext(data []byte) (Context, error) { + var c Context + if err := decodeStrict(data, &c, "the scribe context"); err != nil { + return Context{}, err + } + if c.Type != ContextType || c.SchemaVersion != SchemaVersion { + return Context{}, fmt.Errorf("scribe: the context is %q version %d, want %q version %d", + echo(c.Type), c.SchemaVersion, ContextType, SchemaVersion) + } + return c, nil +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +// maxEchoedBytes caps a payload-derived string quoted into a message, so a +// refusal cannot be made the size of the payload. +const maxEchoedBytes = 120 + +// echo neutralises and caps one payload-derived string for a message. +func echo(s string) string { + s = termsafe.Sanitize(s) + if len(s) > maxEchoedBytes { + cut := maxEchoedBytes + for cut > 0 && !utf8Start(s[cut]) { + cut-- + } + s = s[:cut] + "…" + } + return s +} + +// utf8Start reports whether b begins a UTF-8 sequence, so a cap never splits +// a rune. +func utf8Start(b byte) bool { return b&0xC0 != 0x80 } + +// joinEchoed renders a list of payload-derived names for a message. +func joinEchoed(in []string) string { + out := make([]string, 0, len(in)) + for _, s := range in { + out = append(out, echo(s)) + } + return strings.Join(out, ", ") +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 94157463b..dcb36ca1e 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -390,6 +390,7 @@ func NewRootCommand() *cobra.Command { root.AddCommand(newEmbarkCommand(&asJSON)) root.AddCommand(newSiteCommand(&asJSON)) root.AddCommand(newReadingCommand(&asJSON)) + root.AddCommand(newScribeCommand(&asJSON)) // A cobra usage error (unknown flag, unknown subcommand, stray positional // argument) is a plain error with no ExitCode(), so Run() would map it to diff --git a/internal/surface/cli/scribe.go b/internal/surface/cli/scribe.go new file mode 100644 index 000000000..9d43947c3 --- /dev/null +++ b/internal/surface/cli/scribe.go @@ -0,0 +1,226 @@ +package cli + +// scribe.go is the front door onto internal/core/scribe — the ledger scribe's +// context assembler and output ingest (itd-2609020625402599, +// spc-2609020626045177). +// +// `scribe` is a top-level verb and never a sub-verb of `reading`, because the +// two contexts must never share a front door: a reading is handed a slice of +// the shipped repository and no ledger, and the scribe the ledger and no +// shipped tree (brief invariant 15; adr-2609021016275803). +// +// Nothing here runs the scribe. `assemble` produces the context a scribe session +// is handed and the manifest an auditor checks it by; dispatching it is host +// work, and the host obligation to grant that session nothing else is stated on +// the plugin surface, never claimed as an enforcement this binary performs. +// `ingest` validates what the session returned and writes it through the +// capture verbs. +// +// Exit codes follow `reading`'s shape: 0 when the verb landed, 2 for every +// refusal, with a result rendered first whenever it has something to disclose. + +import ( + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/intentdriven/abcd/internal/core/scribe" + "github.com/intentdriven/abcd/internal/termsafe" + "github.com/spf13/cobra" +) + +// newScribeCommand builds the `scribe` sub-tree. +func newScribeCommand(asJSON *bool) *cobra.Command { + scribeCmd := &cobra.Command{ + Use: "scribe", + Short: "Ledger scribe: assemble its context from the ledger alone, and ingest what it transcribed", + Long: "Build the ledger scribe's context and ingest what the scribe returns.\n\n" + + "The scribe transcribes a reading run's records and the researcher's dispositions into the\n" + + "ledger's declared shapes, and authors nothing. Its context is the reading assembler's exact\n" + + "inverse: ledger content only, drawn from the issue ledger's own directories, and the\n" + + "researcher's supplied text. `assemble` builds it with a manifest of every path passed;\n" + + "`ingest` validates the scribe's output and refuses anything the scribe authored.", + Args: cobra.NoArgs, + RunE: helpRunE, + } + + var run, dispositions, outDir string + var dryRun bool + assembleCmd := &cobra.Command{ + Use: "assemble --run <rdg-N> --dispositions <path>", + Short: "Build a scribe session's context from the ledger and the supplied dispositions", + Long: "Build the context one scribe session is handed, for one ingested reading run.\n\n" + + "The context is positive inclusion at directory grain: the issue ledger's own directories\n" + + "(its reading records, dispositions, admissions, surprises and reframes, and its three status\n" + + "directories), derived from the ledger's directory list, and the researcher's dispositions\n" + + "text read whole. Nothing else is walked, and an item outside that list is refused whatever\n" + + "route it arrived by. The run must be ingested: its records come from the store, never from\n" + + "a raw reading output handed over again.\n\n" + + "The context and a manifest naming every path passed, by hash, are parked in the local tier\n" + + "(or under --out, which may not be a directory a reading's include table reaches). Nothing in\n" + + "the durable record is touched. Both carry the scribe's per-run context stamp.", + Example: " abcd scribe assemble --run rdg-2609250000000001 --dispositions ./dispositions.md --json", + Args: func(_ *cobra.Command, args []string) error { + if len(args) > 0 { + return &exitError{Code: 2, Msg: "scribe assemble: this verb takes no positional argument; " + + "the invocation is --run and --dispositions, and the context is the ledger's own"} + } + return nil + }, + RunE: func(cmd *cobra.Command, _ []string) error { + if run == "" { + return &exitError{Code: 2, Msg: "scribe assemble: --run <rdg-N> is required: the ingested " + + "reading run the session transcribes dispositions for"} + } + if dispositions == "" { + return &exitError{Code: 2, Msg: "scribe assemble: --dispositions <path> is required: the " + + "researcher's dispositions text, which the scribe transcribes and never writes"} + } + cwd := mustCwd() + resolvedOut := resolveAgainst(cwd, outDir) + res, err := scribe.Assemble(scribe.AssembleRequest{ + RepoRoot: captureRoot(cwd), + Run: run, + DispositionsPath: resolveAgainst(cwd, dispositions), + OutDir: resolvedOut, + OutDirLabel: outDir, + DryRun: dryRun, + }) + if err != nil { + return scribeRefusal("scribe assemble", err) + } + // The core was handed the resolved path; the operator is shown the + // string they typed. + if outDir != "" { + res.OutDir = outDir + } + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + renderScribeAssemble(w, res) + }) + }, + } + assembleCmd.Flags().StringVar(&run, "run", "", "the ingested reading run the session transcribes for (rdg-N)") + assembleCmd.Flags().StringVar(&dispositions, "dispositions", "", + "the researcher's dispositions text, read whole and carried verbatim") + assembleCmd.Flags().StringVar(&outDir, "out", "", + "an empty or absent directory the context and the manifest are written to\n"+ + "(default: the local-tier scribe run directory)") + assembleCmd.Flags().BoolVar(&dryRun, "dry-run", false, + "write nothing; with --out the two artefacts still land in that directory") + + var scribeJSON, contextPath string + ingestCmd := &cobra.Command{ + Use: "ingest --scribe-json <path>", + Short: "Validate a scribe session's output and write what it transcribed", + Long: "Validate the JSON a scribe session returned and write its records through the capture verbs.\n\n" + + "The context the session was handed is proven first: it must hash to its parked manifest, and\n" + + "the output must cite that hash. Then the output is refused if the scribe authored anything —\n" + + "a field outside the declared shapes, an item the supplied dispositions never name, or a ground,\n" + + "exit condition or surprise that does not stand verbatim in the supplied text once whitespace\n" + + "is folded — or if it passes over an unanswered item of the run in silence. Nothing is written\n" + + "until all of that holds.\n\n" + + "Dispositions, admissions and surprises are then written in that order through the capture verbs,\n" + + "which apply their own redaction and refusals, the ordering gate included; the first refusal stops\n" + + "the ingest and names what landed before it. Fidelity flags and refusals are reported and never\n" + + "written. Once every write has landed the manifest is promoted beside the run, write-once.", + Example: " abcd scribe ingest --scribe-json ./scribe-output.json --json", + Args: func(_ *cobra.Command, args []string) error { + if len(args) > 0 { + return &exitError{Code: 2, Msg: "scribe ingest: this verb takes no positional argument; " + + "the output names its own run"} + } + return nil + }, + RunE: func(cmd *cobra.Command, _ []string) error { + if scribeJSON == "" { + return &exitError{Code: 2, Msg: "scribe ingest: --scribe-json <path> is required: the JSON " + + "the scribe session returned"} + } + cwd := mustCwd() + res, err := scribe.Ingest(scribe.IngestRequest{ + RepoRoot: captureRoot(cwd), + ScribeJSONPath: resolveAgainst(cwd, scribeJSON), + ContextPath: resolveAgainst(cwd, contextPath), + }) + if err != nil { + // A refusal after something landed discloses what landed, before it + // exits: the operator's handle on the partial state is the render. + if len(res.Landed()) > 0 { + _ = render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + renderScribeIngest(w, res) + }) + } + return scribeRefusal("scribe ingest", err) + } + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + renderScribeIngest(w, res) + }) + }, + } + ingestCmd.Flags().StringVar(&scribeJSON, "scribe-json", "", "path to the JSON the scribe session returned") + ingestCmd.Flags().StringVar(&contextPath, "context", "", + "the context the session was handed, when assemble wrote it under --out\n"+ + "(default: the local-tier scribe run directory of the output's run)") + + scribeCmd.AddCommand(assembleCmd) + scribeCmd.AddCommand(ingestCmd) + return scribeCmd +} + +// resolveAgainst takes an operator's relative path against the working +// directory, which is a transport fact the core does not hold. +func resolveAgainst(cwd, p string) string { + if p == "" || filepath.IsAbs(p) { + return p + } + return filepath.Join(cwd, filepath.FromSlash(p)) +} + +// scribeRefusal is every scribe refusal: exit 2, paths scrubbed, the core's own +// tag replaced by the verb's. +func scribeRefusal(verb string, err error) *exitError { + return &exitError{Code: 2, Msg: verb + ": " + strings.TrimPrefix(scrubPaths(err), "scribe: ")} +} + +// renderScribeAssemble writes one assembly's text render. +func renderScribeAssemble(w io.Writer, res scribe.AssembleResult) { + fmt.Fprintf(w, "abcd scribe assemble — %s: %d ledger record(s) and the supplied dispositions\n", + res.Run, res.ItemCount) + fmt.Fprintf(w, " context stamp: %s\n", res.ContextStamp) + fmt.Fprintf(w, " context sha256: %s (the output cites this)\n", res.ContextSHA256) + if res.Written { + fmt.Fprintf(w, " parked in %s: %s\n", termsafe.Sanitize(res.OutDir), strings.Join(res.Artefacts, ", ")) + } else { + fmt.Fprintln(w, " dry run: nothing written") + } + fmt.Fprintln(w, "Hand the context, and nothing else, to a scribe session that is not a reading session.") +} + +// renderScribeIngest writes one ingest's text render. Every payload-derived +// string is neutralised before it reaches the terminal. +func renderScribeIngest(w io.Writer, res scribe.IngestResult) { + fmt.Fprintf(w, "abcd scribe ingest — %s\n", res.Run) + for _, d := range res.Dispositions { + fmt.Fprintf(w, " disposition %s %s %s\n", d.ID, d.Item, d.State) + } + for _, a := range res.Admissions { + fmt.Fprintf(w, " admission %s %s (disposition %s)\n", a.Admission, a.Item, a.Disposition) + } + for _, s := range res.Surprises { + fmt.Fprintf(w, " surprise %s occasioned by %s\n", s.ID, s.OccasionedBy) + } + if len(res.Outstanding) > 0 { + fmt.Fprintf(w, " outstanding: %s\n", termsafe.Sanitize(strings.Join(res.Outstanding, ", "))) + } + for _, f := range res.FidelityFlags { + fmt.Fprintf(w, " FIDELITY FLAG (unresolved): %q against %q\n", + termsafe.Sanitize(f.First), termsafe.Sanitize(f.Second)) + } + for _, r := range res.Refusals { + fmt.Fprintf(w, " scribe refused %q: %s\n", termsafe.Sanitize(r.Subject), termsafe.Sanitize(r.Reason)) + } + if res.Manifest != "" { + fmt.Fprintf(w, " manifest promoted: %s\n", res.Manifest) + } +} diff --git a/internal/surface/cli/scribe_surface_test.go b/internal/surface/cli/scribe_surface_test.go new file mode 100644 index 000000000..fcb4d837f --- /dev/null +++ b/internal/surface/cli/scribe_surface_test.go @@ -0,0 +1,209 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/scribe" +) + +// scribe_surface_test.go — the front door onto internal/core/scribe +// (spc-2609020626045177). `scribe` is a top-level verb, never a sub-verb of +// `reading`, because the two contexts must never share a front door. + +// scribeOperands is the scribe verb's closed operand set, pinned on the +// readingOperands idiom: it fails CLOSED, so an operand added to either verb +// has to say what it does before it can ship. There is no operand that could +// widen the context, name a path the allow list does not, or set a record's +// content: the supplied dispositions are the researcher's, read whole. +var scribeOperands = map[string][]string{ + "abcd scribe": {}, + "abcd scribe assemble": {"dispositions", "dry-run", "out", "run"}, + "abcd scribe ingest": {"context", "scribe-json"}, +} + +// TestScribeOperandsArePinned walks the registered tree and holds each scribe +// command to its pinned operand set, then proves the pin by adding an operand +// to a tree built for the purpose. +func TestScribeOperandsArePinned(t *testing.T) { + check := func(commands map[string][]string) []string { + var out []string + for path, want := range scribeOperands { + got, ok := commands[path] + if !ok { + out = append(out, path+" is not registered") + continue + } + sort.Strings(got) + if strings.Join(got, ",") != strings.Join(want, ",") { + out = append(out, path+" declares "+strings.Join(got, ",")+", want "+strings.Join(want, ",")) + } + } + return out + } + walk := func() map[string][]string { + out := map[string][]string{} + for _, cmd := range commandSurface(NewRootCommand()) { + if !strings.HasPrefix(cmd.Path, "abcd scribe") { + continue + } + names := []string{} + for _, f := range cmd.Flags { + names = append(names, f.Name) + } + out[cmd.Path] = names + } + return out + } + for _, msg := range check(walk()) { + t.Error(msg) + } + + // Armed: a third operand on assemble is named by the same comparison. + commands := walk() + commands["abcd scribe assemble"] = append(commands["abcd scribe assemble"], "include") + if msgs := check(commands); len(msgs) != 1 || !strings.Contains(msgs[0], "include") { + t.Fatalf("an added operand was not caught: %v", msgs) + } +} + +// scribeRepo is a repository holding one ingested detection run of one item, +// with its commit marker and the scribe definition, entered as the working +// directory. +func scribeRepo(t *testing.T) (repo, item string) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + repo = t.TempDir() + res, err := capture.IngestReading(capture.IngestReadingRequest{ + RepoRoot: repo, Run: "rdg-2609250000000001", Manifest: "sha256:" + strings.Repeat("a", 64), + Position: "detection", Regime: issueschema.ReadingRegime("detection"), + Items: []capture.ReadingItem{{Pattern: "a stated constraint", Body: map[string]string{ + "tension": "t", "constraint_in_play": "c", "why_a_tension": "w"}}}, + }) + if err != nil { + t.Fatal(err) + } + for rel, body := range map[string]string{ + ".abcd/development/readings/rdg-2609250000000001/run.json": `{"run_id":"rdg-2609250000000001"}`, + scribe.DefinitionPath: "---\nname: scribe\n---\n", + } { + p := filepath.Join(repo, 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) + } + } + if err := os.WriteFile(filepath.Join(repo, ".git"), []byte("gitdir: nowhere\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + return repo, res.Records[0].ID +} + +// TestScribeAssembleEchoesTheOperatorsOut: a relative --out means what the +// shell means by it, and the result shows the operator the string they typed +// rather than an absolute path nobody did. +func TestScribeAssembleEchoesTheOperatorsOut(t *testing.T) { + repo, _ := scribeRepo(t) + outside := t.TempDir() + t.Chdir(outside) + disp := filepath.Join(outside, "dispositions.md") + if err := os.WriteFile(disp, []byte("nothing decided\n"), 0o644); err != nil { + t.Fatal(err) + } + // Run from a directory outside the repository, the repository named by + // changing back into it with the --out still relative to where we typed it. + t.Chdir(repo) + rel, err := filepath.Rel(repo, filepath.Join(outside, "session")) + if err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := Run([]string{"scribe", "assemble", "--run", "rdg-2609250000000001", + "--dispositions", disp, "--out", rel, "--json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("scribe assemble exited %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + } + var res scribe.AssembleResult + if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { + t.Fatalf("--json is not a result: %v\n%s", err, stdout.String()) + } + if res.OutDir != rel { + t.Errorf("out_dir = %q, want the operator's own %q", res.OutDir, rel) + } + if strings.Contains(stdout.String(), outside) { + t.Errorf("the result carries an absolute path nobody typed:\n%s", stdout.String()) + } + if _, err := os.Stat(filepath.Join(outside, "session", scribe.ContextFileName)); err != nil { + t.Fatalf("the context did not land where the operator's relative --out points: %v", err) + } + + // Every refusal exits 2: a positional argument, a missing --run. + for _, args := range [][]string{ + {"scribe", "assemble", "rdg-2609250000000001"}, + {"scribe", "assemble", "--dispositions", disp}, + {"scribe", "assemble", "--run", "rdg-2609250000000001"}, + } { + stdout.Reset() + stderr.Reset() + if code := Run(args, &stdout, &stderr); code != 2 { + t.Errorf("%v exited %d, want 2\n%s", args, code, stderr.String()) + } + } +} + +// TestScribeIngestRendersOnRefusal: a refusal after something landed renders +// what landed before it exits 2, so the operator can see the partial state. +func TestScribeIngestRendersOnRefusal(t *testing.T) { + repo, item := scribeRepo(t) + ground := "the constraint the reading names is real and binds the verb as shipped" + disp := filepath.Join(t.TempDir(), "dispositions.md") + if err := os.WriteFile(disp, []byte(item+": accepted — "+ground+".\nSurprise at "+item+": none here\n"), 0o644); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + if code := Run([]string{"scribe", "assemble", "--run", "rdg-2609250000000001", "--dispositions", disp, "--json"}, + &stdout, &stderr); code != 0 { + t.Fatalf("assemble exited %d: %s", code, stderr.String()) + } + var asm scribe.AssembleResult + if err := json.Unmarshal(stdout.Bytes(), &asm); err != nil { + t.Fatal(err) + } + // The disposition lands; the surprise's text is below the substance floor, + // so the surprise verb refuses it after the disposition landed. + payload := map[string]any{ + "_type": scribe.OutputType, "run": "rdg-2609250000000001", "context_sha256": asm.ContextSHA256, + "dispositions": []map[string]any{{"item": item, "state": "accepted", "grounds": ground}}, + "surprises": []map[string]any{{"occasioned_by": item, "text": "none here"}}, + } + raw, _ := json.Marshal(payload) + p := filepath.Join(t.TempDir(), "out.json") + if err := os.WriteFile(p, raw, 0o644); err != nil { + t.Fatal(err) + } + stdout.Reset() + stderr.Reset() + code := Run([]string{"scribe", "ingest", "--scribe-json", p, "--json"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("a refused ingest exited %d, want 2\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + } + var res scribe.IngestResult + dec := json.NewDecoder(bytes.NewReader(stdout.Bytes())) + if err := dec.Decode(&res); err != nil { + t.Fatalf("the refusal rendered no result first: %v\n%s", err, stdout.String()) + } + if len(res.Dispositions) != 1 || res.Dispositions[0].Item != item { + t.Fatalf("the refusal's render names %+v as landed, want the disposition", res.Dispositions) + } + _ = repo +} From 0aa1eda69d5ad74b08f5a04027df7480043e873c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:13:42 +0100 Subject: [PATCH 25/78] docs(agents): the scribe definition names its verbs and the ledger's full list agents/scribe.md moves to 0.2.0 (MINOR). Its Inputs are the list `scribe assemble` builds the context from, which completes the ledger enumeration with the admissions, surprises and reframes stores and says the run's reading records come from the store rather than as a raw output handed over again; the access rule, ledger content only, is unchanged. Delivery names the two verbs and the output document with its four parts in place of "there is no ingest verb". The output's _type is spelled out in words there because the access-rule test reads any solidus in the definition as a path. TestScribeInputsMatchTheLedgerDirs holds the Inputs list to scribe.AllowList(), and TestScribeDeliveryNamesTheVerb holds Delivery to the verbs and the output's parts; both were red against 0.1.0. The agents changelog carries the bump, and the brief's scribe protocol names `scribe assemble`, `scribe ingest` and the separation check. Part of itd-2609020625402599 / spc-2609020626045177. Assisted-by: Claude:claude-opus-5-5 --- .../brief/05-internals/01-agents.md | 32 ++++++--- agents/CHANGELOG.md | 18 +++++ agents/scribe.md | 60 ++++++++++------ internal/core/lint/scribecontract_test.go | 68 ++++++++++++++++++- 4 files changed, 145 insertions(+), 33 deletions(-) diff --git a/.abcd/development/brief/05-internals/01-agents.md b/.abcd/development/brief/05-internals/01-agents.md index 94ceafee6..01c8f74ff 100644 --- a/.abcd/development/brief/05-internals/01-agents.md +++ b/.abcd/development/brief/05-internals/01-agents.md @@ -174,9 +174,10 @@ The `scribe` is machine assistance in maintaining the ledger, and its access rul is the exact inverse of the assembler's (invariant 15 in [`02-constraints/03-invariants.md`](../02-constraints/03-invariants.md), which binds this section). The assembler passes a reading a positively included slice of -the shipped repository and no ledger; the scribe receives ledger content plus the -reading output it is transcribing, and never the shipped repository as an object -of judgement. **No session holds both a reading and the ledger.** The scribe is +the shipped repository and no ledger; the scribe receives ledger content, the +run's reading records among it as the store holds them, plus the researcher's +supplied dispositions, and never the shipped repository as an object of +judgement. **No session holds both a reading and the ledger.** The scribe is also not a consumer of the session-transcript store: that store's consumer list is enumerated in the same invariant, and adding the scribe to it is an invariant change rather than a code path. @@ -186,11 +187,16 @@ positive inclusion is what excludes the path nobody thought to name, including a record type the list has never heard of. Two tests in `internal/core/lint` hold the definition to that, and their reach is exactly what they say: they prove the definition names the right paths, not that a host assembled the right context. -Mechanical assembly belongs to the ingest verb. +Mechanical assembly belongs to `abcd scribe assemble`, which builds the context +from an allow list derived from the ledger's own directory list and parks it with +a manifest of every path passed; a third test holds the definition's list to +that function ([`04-surfaces/31-scribe.md`](../04-surfaces/31-scribe.md)). The mechanical path exists beside the scribe: `abcd reading ingest` validates the -output a reading returned and writes its reading records, and `abcd capture -disposition` writes the researcher's answer to one item. The scribe is the +output a reading returned and writes its reading records, `abcd capture +disposition` writes the researcher's answer to one item, and `abcd scribe ingest` +validates what a scribe session returned, refuses anything it authored, and +writes it through the capture verbs' own functions. The scribe is the transcription assistant of the session in which that material is prepared, and four rules bind that session: @@ -199,15 +205,19 @@ four rules bind that session: readings held for transcription is the pressure that invents one. 2. **The reading run and the scribe run are separate host sessions**, always. Each is retained under its own session id, and the transcript store is what shows - two distinct sessions. The honest limit: the store shows that two sessions - exist and that neither carries the other's material; it cannot enforce that the - practice held, because the separation happens in the host before anything is - retained. + two distinct sessions. Every reading bundle and every scribe context carries + a per-run context stamp, and `abcd history separation` names a retained + transcript carrying the reading stamp and the scribe stamp of one run. The + honest limit: the check reports what a host retained; it cannot enforce that + the practice held, because the separation happens in the host before anything + is retained, and where nothing stamped was retained it reports the property + unobserved rather than held. 3. **The transcribed material is committed through the ordinary record path.** The reading and disposition stores are declared record families, so `record_schema` holds each record to its shape at the gate, and the writing verbs validate before they write. A record the scribe transcribed reaches the tree through a - verb, never by a hand-placed file. + verb — `abcd scribe ingest`, beside `abcd reading ingest` and `abcd capture + disposition` — never by a hand-placed file. 4. **A fidelity flag is carried to the researcher unresolved.** The scribe may flag an internal inconsistency in the material it is transcribing, because that is transcription fidelity rather than judgement. It may never propose a diff --git a/agents/CHANGELOG.md b/agents/CHANGELOG.md index 573d6432f..055af42ea 100644 --- a/agents/CHANGELOG.md +++ b/agents/CHANGELOG.md @@ -12,6 +12,24 @@ over the brief's earlier `1.0.0`-at-close expectation). The four M6 synthesis agents below entered at `0.1.0`, wired to their `abcd disembark` verbs and unmeasured; `lifeboat-oracle` has since become `lifeboat-reviewer` at `0.1.1`. +## 2026-09-25 (itd-2609020625402599 — the scribe's context is assembled and its output is ingested) + +The scribe is a verb rather than a protocol. `abcd scribe assemble` builds the +scribe's context from an allow list derived from the issue ledger's own +directory list, plus the researcher's supplied dispositions, and `abcd scribe +ingest` validates the scribe's return and writes it through the capture verbs, +refusing anything the scribe authored (adr-2609021016275803). + +### scribe 0.2.0 + +MINOR: the Inputs list is the assembler's allow list, which completes the +ledger enumeration with the admissions, surprises and reframes stores; the run's +reading records are named as coming from the store, never as a raw output handed +over again; and Delivery names the two verbs and the output document with its +four parts in place of "there is no ingest verb". The access rule is unchanged — +ledger content only — and so are the record shapes, the fidelity-flag rule and +the contribution stamp. Unmeasured, as before. + ## 2026-09-25 (itd-2609020625405251 — a detection item cites the condition it names) Iteration 2's condition disposition joins a researcher's mark on a scope diff --git a/agents/scribe.md b/agents/scribe.md index 03c323b41..61c8d4d36 100644 --- a/agents/scribe.md +++ b/agents/scribe.md @@ -6,7 +6,7 @@ description: >- the assembler's exact inverse — ledger content only, never the shipped repository as an object of judgement. It authors nothing; it may flag a fidelity problem in the material it is transcribing, and it never proposes a resolution. -prompt_version: 0.1.0 +prompt_version: 0.2.0 reads_untrusted_input: true capability_scope: task_classes: [surface_render] @@ -41,18 +41,24 @@ capability_scope: Positive inclusion: a source not named below is excluded, including a record type this list has never heard of. The list is what you may be given; you fetch -nothing. +nothing. It is the list `abcd scribe assemble` builds your context from, derived +from the ledger's own directories, and your context arrives as one document +holding exactly these and nothing else. -- `.abcd/work/issues/readings/` — the reading records already on file, run by - run. These are what a new item is a sibling of. -- `.abcd/work/issues/dispositions/` — the dispositions already recorded against - an item, including the standing one a new disposition supersedes. - `.abcd/work/issues/open/`, `.abcd/work/issues/resolved/`, `.abcd/work/issues/wontfix/` — the issue ledger's three status directories, which are the ledger's other content. -- **The reading output you are transcribing**, and **the researcher's - dispositions**, both supplied to you as material by whoever runs the session. - They arrive as text. They are not a repository path, and you never resolve one. +- `.abcd/work/issues/readings/` — the reading records on file, run by run. The + run you are transcribing dispositions for is among them: its records come from + the store, already written, and you are never handed a raw reading output. +- `.abcd/work/issues/dispositions/` — the dispositions already recorded against + an item, including the standing one a new disposition supersedes. +- `.abcd/work/issues/admissions/` — the admissions already recorded, run by run. +- `.abcd/work/issues/surprises/` — the surprises already recorded. +- `.abcd/work/issues/reframes/` — the reframes already recorded. +- **The researcher's dispositions**, supplied to you as text inside the context + by whoever runs the session. It is not a repository path, and you never resolve + one. ## Never in context @@ -159,15 +165,27 @@ hand-run form of the record's origin keys and stands until those keys ship. ## Delivery -There is no ingest verb, so what you emit is committed through the ordinary -record path and judged there by the record gates. Be clear about what those gates -know: until spc-58's reading and disposition stores land, they know nothing of -these shapes, and the record lint refuses their directory as an undeclared bucket -— a malformed record and a well-formed one are refused alike. Until then the -shapes above are held by this definition and by whoever reviews the commit, not -by a schema. Emit four things and nothing beyond them: the records as they are -to be filed; the `fidelity_flags` list when there is one; every item you were -given no disposition for, named as outstanding; and anything you refused, named -with the reason you refused it. An item you say nothing about reads as an item -nobody raised, and material you drop silently reads as material that never -arrived — so silence is not one of your options. You never write files yourself. +Your session sits between two verbs. `abcd scribe assemble` builds the context +you are handed; `abcd scribe ingest` validates what you return and writes it +through the ledger's own record verbs, which apply the record gates, the +redaction and the ordering rules. You never write files yourself. + +Return one JSON document and nothing beyond it. Its `_type` is the scribe output +tag: `abcd.scribe.output`, a solidus, then `1`. It carries `run`, the run your +context names, and `context_sha256`, the hash whoever runs the session gives you +with the context. Then the four things: + +- the records as they are to be filed: `dispositions` (each with `item`, + `state`, `grounds`, and `exit_condition`, `supersedes` and `recurs` where the + researcher gave them), `admissions` (each with `item` and `grounds`) and + `surprises` (each with `occasioned_by` and `text`); +- `fidelity_flags`, each naming its `first` and `second` piece of material; +- `outstanding`, every item of the run you were given no disposition for; +- `refusals`, anything you refused, each with its `subject` and the `reason`. + +A key outside these is refused whole, and so is a ground, an exit condition or a +surprise whose words are not the researcher's: the verb checks that every word +you carry already stands in the supplied dispositions. An item you say nothing +about reads as an item nobody raised, and material you drop silently reads as +material that never arrived — so silence is not one of your options, and the +verb refuses a payload that passes over an item of the run. diff --git a/internal/core/lint/scribecontract_test.go b/internal/core/lint/scribecontract_test.go index bed0879c9..c27e4e833 100644 --- a/internal/core/lint/scribecontract_test.go +++ b/internal/core/lint/scribecontract_test.go @@ -8,7 +8,9 @@ package lint_test // // These cases hold the shipped DEFINITION to that rule, and they are honest about // their reach: they prove the prompt names the right paths, not that a host -// assembled the right context. Mechanical assembly belongs to the ingest verb. +// assembled the right context. Mechanical assembly belongs to the scribe +// verb's assembler, whose allow list TestScribeInputsMatchTheLedgerDirs holds +// this definition's list to. // // They sit in the external test package beside preflightgates_test.go, the other // case that reads the real repository's shipped files, and share its readRepoFile. @@ -27,6 +29,7 @@ import ( "unicode" "github.com/intentdriven/abcd/internal/core/lint" + "github.com/intentdriven/abcd/internal/core/scribe" ) // scribePromptRel is the shipped definition; scribeCanaryRel its injection canary. @@ -565,3 +568,66 @@ func TestScribePromptSatisfiesTheContract(t *testing.T) { t.Fatalf("no %q finding over a deliberately broken agent tree; the rule id this case filters on is stale", scribeAgentContractRule) } + +// scribeSection returns the body of the definition's section whose heading +// starts with name, up to the next heading of the same or higher level. +func scribeSection(t *testing.T, prompt, name string) string { + t.Helper() + re := regexp.MustCompile(`(?m)^##[ \t]+` + regexp.QuoteMeta(name) + `.*$`) + loc := re.FindStringIndex(prompt) + if loc == nil { + t.Fatalf("%s carries no %q section", scribePromptRel, name) + } + rest := prompt[loc[1]:] + if next := regexp.MustCompile(`(?m)^##[ \t]`).FindStringIndex(rest); next != nil { + rest = rest[:next[0]] + } + return rest +} + +// scribeLedgerPathRe matches one backticked ledger directory in the Inputs list. +var scribeLedgerPathRe = regexp.MustCompile("`(" + regexp.QuoteMeta(scribeLedgerRoot) + "[a-z]+)/`") + +// TestScribeInputsMatchTheLedgerDirs holds the definition's allow list to the +// function the verb assembles from: the Inputs section names exactly the +// directories scribe.AllowList() derives from the ledger's own directory list, +// so the definition and the assembler describe one set and a family the ledger +// declares later is a red test until the definition names it +// (spc-2609020626045177). +func TestScribeInputsMatchTheLedgerDirs(t *testing.T) { + root := filepath.Join("..", "..", "..") + inputs := scribeSection(t, readRepoFile(t, root, scribePromptRel), "Inputs") + got := map[string]bool{} + for _, m := range scribeLedgerPathRe.FindAllStringSubmatch(inputs, -1) { + got[m[1]] = true + } + want := map[string]bool{} + for _, dir := range scribe.AllowList() { + want[dir] = true + if !got[dir] { + t.Errorf("%s's Inputs do not name %s/, which the assembler passes", scribePromptRel, dir) + } + } + for dir := range got { + if !want[dir] { + t.Errorf("%s's Inputs name %s/, which the assembler does not pass", scribePromptRel, dir) + } + } +} + +// TestScribeDeliveryNamesTheVerb: the definition's Delivery section names the +// two verbs its session sits between and the four outputs it returns, and no +// longer says there is no ingest verb. +func TestScribeDeliveryNamesTheVerb(t *testing.T) { + root := filepath.Join("..", "..", "..") + delivery := scribeSection(t, readRepoFile(t, root, scribePromptRel), "Delivery") + for _, want := range []string{"abcd scribe assemble", "abcd scribe ingest", "`dispositions`", "`admissions`", + "`surprises`", "`fidelity_flags`", "`outstanding`", "`refusals`", "`context_sha256`"} { + if !strings.Contains(delivery, want) { + t.Errorf("%s's Delivery section does not name %s", scribePromptRel, want) + } + } + if strings.Contains(delivery, "There is no ingest verb") { + t.Errorf("%s's Delivery section still says there is no ingest verb", scribePromptRel) + } +} From 2462737fcd4341f2b649dceec652fd57fb6983c8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:13:44 +0100 Subject: [PATCH 26/78] test(evals): plant a scribe manifest beside a prior run as exhaust The read-block fixture gains the scribe manifest a completed ingest promotes beside its run, carrying the EXHAUST sentinel, and the class's homes and count move with it. TestPriorRunExhaustNeverReaches then covers ac-8 at every assembling position with no new assertion. It is a regression guard rather than a falsifier: the file matches no include row and sits under a denied segment, so it cannot leak today; it is planted so a row or segment change that would let it leak is caught by class and position. Part of itd-2609020625402599 / spc-2609020626045177. Assisted-by: Claude:claude-opus-5-5 --- evals/coldreading_fixture_test.go | 8 +++++++- .../readings/rdg-2608300900000001/scribe-manifest.json | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 evals/testdata/cold-reading/baseline/.abcd/development/readings/rdg-2608300900000001/scribe-manifest.json diff --git a/evals/coldreading_fixture_test.go b/evals/coldreading_fixture_test.go index 3ae3f2b74..1a2f225ec 100644 --- a/evals/coldreading_fixture_test.go +++ b/evals/coldreading_fixture_test.go @@ -222,6 +222,12 @@ var sentinelClasses = []sentinelClass{ Name: "EXHAUST", Homes: []string{ "repo:.abcd/development/readings/rdg-2608300900000001/manifest.json", + // The scribe's manifest, promoted beside the run it transcribed for + // (spc-2609020626045177). It names ledger paths by construction, so + // it is planted as a regression guard: a row or segment change that + // let the readings family travel would carry the ledger with it + // (itd-2609020625402599 ac-8). + "repo:.abcd/development/readings/rdg-2608300900000001/scribe-manifest.json", "repo:.abcd/work/issues/readings/rdi-1-a-prior-reading.md", "repo:.abcd/work/issues/dispositions/dsp-1-a-prior-disposition.md", // The SECOND widening run's items. The comparative exception admits @@ -232,7 +238,7 @@ var sentinelClasses = []sentinelClass{ "repo:.abcd/work/issues/readings/" + dispositionedWideningRun + "/rdi-201.md", "repo:.abcd/work/issues/readings/" + dispositionedWideningRun + "/rdi-202.md", }, - Count: 5, + Count: 6, Why: "itd-183: manifests, reading records and dispositions are warm on the next " + "run, so the instrument's own output is never its input — with the one " + "positional exception adr-2609021016272867 states, which reaches ONE run", diff --git a/evals/testdata/cold-reading/baseline/.abcd/development/readings/rdg-2608300900000001/scribe-manifest.json b/evals/testdata/cold-reading/baseline/.abcd/development/readings/rdg-2608300900000001/scribe-manifest.json new file mode 100644 index 000000000..3d5cb823c --- /dev/null +++ b/evals/testdata/cold-reading/baseline/.abcd/development/readings/rdg-2608300900000001/scribe-manifest.json @@ -0,0 +1,6 @@ +{ + "_type": "abcd.scribe.manifest/1", + "schema_version": 1, + "run": "rdg-2608300900000001", + "note": "ABCD-EVAL-SENTINEL-EXHAUST" +} From 044454c0a7e2128ac33cdac2bd590167f5350940 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:14:30 +0100 Subject: [PATCH 27/78] chore: ship itd-2609020625402599 by closing spc-2609020626045177 The scribe verb, the per-run context stamp and the session-separation check are delivered: `scribe assemble` and `scribe ingest`, the stamp on every reading bundle and scribe context, `history separation` and its line on `history list`, the definition at 0.2.0 and the read-block plant. Impact additive, as the intent declares. One departure from the spec's letter: its surface chapter is 31-scribe.md with register row 31, because row and chapter 24 belong to decide; the spec's "row 24" predates that. Delivers: itd-2609020625402599 Assisted-by: Claude:claude-opus-5-5 --- ...-s-context-is-assembled-and-its-output-is-ingested.md | 9 +++++---- ...-s-context-is-assembled-and-its-output-is-ingested.md | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) rename .abcd/development/intents/{planned => shipped}/itd-2609020625402599-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md (82%) rename .abcd/development/specs/{open => closed}/spc-2609020626045177-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md (98%) diff --git a/.abcd/development/intents/planned/itd-2609020625402599-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md b/.abcd/development/intents/shipped/itd-2609020625402599-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md similarity index 82% rename from .abcd/development/intents/planned/itd-2609020625402599-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md rename to .abcd/development/intents/shipped/itd-2609020625402599-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md index 38e3c01d9..61cfc1b9c 100644 --- a/.abcd/development/intents/planned/itd-2609020625402599-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md +++ b/.abcd/development/intents/shipped/itd-2609020625402599-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md @@ -14,7 +14,7 @@ production_mode: dictated-and-formatted # The scribe's context is assembled and its output is ingested by a verb, and the record can show that no session held both a reading and the ledger -Typed links: `builds_on` [itd-188](../shipped/itd-188-machine-assistance-in-maintaining-the-ledger-without-any-con.md) (the scribe definition), [itd-183](../shipped/itd-183-the-cold-reading-sees-exactly-what-the-assembler-passes-posi.md) (the assembler and manifest idiom), [itd-180](../shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) (the disposition validator), [itd-185](../shipped/itd-185-one-ingest-verb-validates-every-cold-reading-output-includin.md) (the ingest idiom); `refines` [itd-188](../shipped/itd-188-machine-assistance-in-maintaining-the-ledger-without-any-con.md) (the protocol becomes a verb). +Typed links: `builds_on` [itd-188](itd-188-machine-assistance-in-maintaining-the-ledger-without-any-con.md) (the scribe definition), [itd-183](itd-183-the-cold-reading-sees-exactly-what-the-assembler-passes-posi.md) (the assembler and manifest idiom), [itd-180](itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) (the disposition validator), [itd-185](itd-185-one-ingest-verb-validates-every-cold-reading-output-includin.md) (the ingest idiom); `refines` [itd-188](itd-188-machine-assistance-in-maintaining-the-ledger-without-any-con.md) (the protocol becomes a verb). ## Press Release @@ -24,7 +24,7 @@ Typed links: `builds_on` [itd-188](../shipped/itd-188-machine-assistance-in-main ## Why This Matters -[itd-188](../shipped/itd-188-machine-assistance-in-maintaining-the-ledger-without-any-con.md) specifies the scribe as a definition with an inverse access rule, hand-run until an ingest verb lands, with two acceptance criteria: the scribe's assembled context contains ledger content and no shipped-tree material, and each reading run and each scribe run is a distinct retained session with no session holding both. Iteration 1 shipped the definition with the access rule stated in its allow list, and its own text says "There is no ingest verb". The fidelity verdict found both criteria met by declaration rather than by mechanism: no assembler runs for the scribe, and session retention cannot show that no session held both. +[itd-188](itd-188-machine-assistance-in-maintaining-the-ledger-without-any-con.md) specifies the scribe as a definition with an inverse access rule, hand-run until an ingest verb lands, with two acceptance criteria: the scribe's assembled context contains ledger content and no shipped-tree material, and each reading run and each scribe run is a distinct retained session with no session holding both. Iteration 1 shipped the definition with the access rule stated in its allow list, and its own text says "There is no ingest verb". The fidelity verdict found both criteria met by declaration rather than by mechanism: no assembler runs for the scribe, and session retention cannot show that no session held both. The scribe exists so that machine assistance in maintaining the ledger remains available without any context holding both ledger content and a reading. That property is the mirror of the read block, and the read block is held by an assembler, a manifest and an eval. The scribe deserves the same three things, or its half of the wall is an assertion. @@ -72,7 +72,7 @@ We expect an assembler with an allow list to hold the inverse access rule for th ## Prior Art -- [itd-188](../shipped/itd-188-machine-assistance-in-maintaining-the-ledger-without-any-con.md) and its spec (the definition), [itd-183](../shipped/itd-183-the-cold-reading-sees-exactly-what-the-assembler-passes-posi.md) (the assembler and manifest idiom), [itd-180](../shipped/itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) (the disposition validator), the `abcd history` store. +- [itd-188](itd-188-machine-assistance-in-maintaining-the-ledger-without-any-con.md) and its spec (the definition), [itd-183](itd-183-the-cold-reading-sees-exactly-what-the-assembler-passes-posi.md) (the assembler and manifest idiom), [itd-180](itd-180-a-cold-reading-s-findings-land-as-reading-records-and-the-re.md) (the disposition validator), the `abcd history` store. - The cold-reading rulings of 2026-08-28 in the decision log. ## Open Questions @@ -81,7 +81,8 @@ None. The flagged decisions are adopted as adr-2609021016275803. ## Audit Notes -_Empty. Populated by intent-auditor when intent moves to shipped/._ +<!-- abcd-review: OWED receipt=rcp-6654dbf923b3 --> +Fidelity review OWED (receipt rcp-6654dbf923b3). ## Grounds diff --git a/.abcd/development/specs/open/spc-2609020626045177-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md b/.abcd/development/specs/closed/spc-2609020626045177-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md similarity index 98% rename from .abcd/development/specs/open/spc-2609020626045177-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md rename to .abcd/development/specs/closed/spc-2609020626045177-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md index c5b99ba37..4583d3cc1 100644 --- a/.abcd/development/specs/open/spc-2609020626045177-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md +++ b/.abcd/development/specs/closed/spc-2609020626045177-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md @@ -10,7 +10,7 @@ production_mode: dictated-and-formatted ## Summary spc-2609020626045177 delivers -[itd-2609020625402599](../../intents/planned/itd-2609020625402599-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md). +[itd-2609020625402599](../../intents/shipped/itd-2609020625402599-the-scribe-s-context-is-assembled-and-its-output-is-ingested.md). `abcd scribe assemble --run <rdg-N>` builds the scribe's context for one ingested run from the ledger's allow list, which is derived from the ledger's own directory constants, and from the researcher's supplied dispositions text: @@ -26,7 +26,7 @@ at capture which stamps a transcript carried, and `abcd history` gains a check that names a retained transcript carrying both stamps of one run, reports that no retained transcript carries two stamps of one run when none does, and says when the property is unobserved. -[spc-66](../closed/spc-66-machine-assistance-in-maintaining-the-ledger-without-any-con.md) +[spc-66](spc-66-machine-assistance-in-maintaining-the-ledger-without-any-con.md) met both of [itd-188](../../intents/shipped/itd-188-machine-assistance-in-maintaining-the-ledger-without-any-con.md)'s criteria by declaration; this spec meets them by mechanism. @@ -213,7 +213,7 @@ because silence is not one of the scribe's options. Writes go through the verbs' own functions, in payload order: `capture.Disposition`, then `capture.Admit`, then `capture.Surprise` (the last two delivered by -[spc-2609020626040342](../closed/spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md)), +[spc-2609020626040342](spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md)), each under the ledger lock it takes for itself, each inheriting the redaction and the refusals it already applies. The verb adds no validation path of its own. Two inherited refusals are named here because a scribe payload meets From 68ed091b91452f08f6034ebb94b1e33b0a95599e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:33:28 +0100 Subject: [PATCH 28/78] fix(scribe): the plugin page carries the binary-resolution ladder commands/scribe.md named the binary without the ladder paragraph every command page carries, so TestCommandSurfaceResolvesBinaryFromPluginRoot refused it in the preflight's unit stage. The page gains the standard paragraph and the user-input line. Part of itd-2609020625402599 / spc-2609020626045177. Assisted-by: Claude:claude-opus-5-5 --- commands/scribe.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/commands/scribe.md b/commands/scribe.md index cef7e46b5..459ad8548 100644 --- a/commands/scribe.md +++ b/commands/scribe.md @@ -123,3 +123,14 @@ each with its id; `outstanding`; every `fidelity_flags` entry, **unresolved** never pick one side of a flag, it is the researcher's to resolve; every `refusals` entry; and `manifest`, the promoted manifest beside the run. Flags and refusals are never written into a record. + +**Binary resolution.** Run `"${CLAUDE_PLUGIN_ROOT}/abcd"` — a plugin install +provisions the binary into the plugin root, so this is the rung that fires for a +plugin user. If that path does not exist, try `abcd` on `PATH`; if that fails +too, you are in a source checkout of this repo, where — and only there — +`go run ./cmd/abcd` works, the published payload carrying no `cmd/`. To put a +binary on `PATH`, run `ahoy install` through whichever rung just resolved: +`"${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install`, `abcd ahoy install`, or +`go run ./cmd/abcd ahoy install` in a source checkout. + +**User input:** $ARGUMENTS From 41a66971b3eeea21ffdda9adc8e2cdb5095cf09e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:35:59 +0100 Subject: [PATCH 29/78] feat(lint): the principles family is a declared record store with typed claims The principles family joins record_schema's stores as `prn`, slug-keyed (adr-2609021016270132): an entry's handle is prn-<filename stem>, it issues no ordinal, and an id a typed entry carries is compared to that handle as a string. The schema index now keys on the rendered handle, so a clean store of thirty entries is thirty handles and not thirty claims on prn-0; the record graph emits each entry as a `principle` node. Four rules judge what a principle claims, over one scan: - principle_untyped (warn): an entry carrying none of claim_type, reference, comparison, evidence. A count, not a fault; 36 today. - principle_claims (blocker): a typed entry missing a key, missing its id, carrying a blank value (only the literal `null` declines a claim), a value outside its grammar (`mechanism` reads as `causal`), a duplicated evidence member, or a `**The rule.**` paragraph carrying a record handle or a link. - principle_inheritance (warn): evidence resting on a narrowed (with the narrowing) or untested condition, or naming a record or condition the repository cannot resolve, or a condition two shipped intents carry. - principle_falsified (blocker): evidence resting on a condition whose standing disposition, read with condition.Standing, is falsified. The labelled paragraph and the in-text handle grammar are leaf helpers (mdrecord.LabelledParagraph, recordid.HandleInText) so the reading assembler projects and verifies against the same two readings. The site keeps publishing principles under the stem its pages carry and drops the graph's prn- copies, so one record is not published twice. Decisions not in the record: a WRONG id is record_schema's finding only (its filename leg), so principle_claims speaks to a missing id and does not add a second finding on the same line; evidence is read as a flow sequence, and a block sequence is refused with that remedy; a retired ADR id resolves for inheritance on the same bound record_schema uses. Part of itd-2609020625405170 / spc-2609020626042471. Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/05-internals/06-lint.md | 2 +- .abcd/development/principles/README.md | 25 + .abcd/record-lint.json | 19 +- internal/core/lint/config.go | 4 + .../core/lint/duplicatekeyreaders_test.go | 41 ++ internal/core/lint/graph.go | 26 +- internal/core/lint/lint.go | 9 + internal/core/lint/principles.go | 480 ++++++++++++++++++ internal/core/lint/principles_test.go | 450 ++++++++++++++++ internal/core/lint/schema.go | 96 +++- internal/core/mdrecord/labelled_test.go | 41 ++ internal/core/mdrecord/mdrecord.go | 40 ++ internal/core/recordid/handleintext_test.go | 34 ++ internal/core/recordid/resolve.go | 33 +- internal/core/site/build.go | 8 +- internal/core/site/fixture_test.go | 3 +- internal/core/site/foundations.go | 54 +- internal/core/site/recordjson.go | 9 +- 18 files changed, 1320 insertions(+), 54 deletions(-) create mode 100644 internal/core/lint/principles.go create mode 100644 internal/core/lint/principles_test.go create mode 100644 internal/core/mdrecord/labelled_test.go create mode 100644 internal/core/recordid/handleintext_test.go diff --git a/.abcd/development/brief/05-internals/06-lint.md b/.abcd/development/brief/05-internals/06-lint.md index 9ac979e89..80eabfbea 100644 --- a/.abcd/development/brief/05-internals/06-lint.md +++ b/.abcd/development/brief/05-internals/06-lint.md @@ -6,7 +6,7 @@ Canonical reference for the lint engine in `internal/core/lint` — the determin The lint engine lives in `internal/core/lint` (Go). It is driven by two armed, deterministic gates, each reading its own JSON rule config as the single source of truth for the armed rule set: -- **Record-currency** (`cmd/record-lint`, config `.abcd/record-lint.json`) lints the markdown design record under `.abcd/development/` for drift: frontmatter and schema shape, resolvable cross-links, directory coverage, intent-lifecycle placement, retired or banned tokens, index-drift on generated regions, delivery-state agreement, citation currency, and record ids cited in record PROSE (`prose_citation_resolves`: an id written in a record's prose must name a record that exists, unless the author marks the line `<!-- record-lint: illustrative -->` or `<!-- record-lint: forward-looking -->`, or the id is carried by the ratcheting baseline `.abcd/prose-citations-baseline.json`; §1.1 says exactly what counts as prose). `make record-lint` runs it, and CI runs the same job on every push. +- **Record-currency** (`cmd/record-lint`, config `.abcd/record-lint.json`) lints the markdown design record under `.abcd/development/` for drift: frontmatter and schema shape, a principle's typed claims and the scope conditions its evidence inherits, resolvable cross-links, directory coverage, intent-lifecycle placement, retired or banned tokens, index-drift on generated regions, delivery-state agreement, citation currency, and record ids cited in record PROSE (`prose_citation_resolves`: an id written in a record's prose must name a record that exists, unless the author marks the line `<!-- record-lint: illustrative -->` or `<!-- record-lint: forward-looking -->`, or the id is carried by the ratcheting baseline `.abcd/prose-citations-baseline.json`; §1.1 says exactly what counts as prose). `make record-lint` runs it, and CI runs the same job on every push. - **Docs-currency** (`abcd docs lint`, config `.abcd/docs-lint.json`) lints `docs/` and the repo-root prose for change-narration (past-tense drift such as "previously" or "formerly"), broken relative links, stray root markdown, host-name leakage, British-spelling drift, em-dash-in-list-item punctuation, and citation health. `make docs-lint` runs it, and CI runs it on the Linux leg. Each rule carries a severity (`blocker`, `warn`, or `info`) resolved from its config entry; the severity model is §2. A rule is enabled, disabled, or re-severitied by editing its config entry, so the armed set is always the JSON config, never this document. diff --git a/.abcd/development/principles/README.md b/.abcd/development/principles/README.md index 55875f542..8b32a09f7 100644 --- a/.abcd/development/principles/README.md +++ b/.abcd/development/principles/README.md @@ -34,3 +34,28 @@ rungs. Record-shaped work may declare a degenerate ladder (principle = MVP, or topping out at MVP). Two rules: articulate the full ladder for every candidate, and never fabricate an absent rung. Provenance: [`../research/notes/2026-07-09-practice-mvp-tool-extraction.md`](../research/notes/2026-07-09-practice-mvp-tool-extraction.md). + +**Typed claims.** The family is a declared record store +([adr-2609021016270132](../decisions/adrs/2609021016270132-the-principles-family-is-a-declared-record-store-whose-entri.md)): +an entry's handle is `prn-<filename stem>`, and an entry may open with a +frontmatter block declaring what kind of claim it makes and what it rests on. + +```yaml +--- +id: prn-<filename stem> +claim_type: causal # criterion, causal or context (mechanism reads as causal) +reference: "abcd lint" # a record handle, or a double-quoted surface name +comparison: "What was compared to produce it, in one sentence." +evidence: [itd-181, cond-2608311949582375] # record handles and scope-condition identities +--- +``` + +A key considered and declined is the literal `null`; an absent key is a claim +not carried. Population is forward-only: an entry carrying none of the four keys +is counted by the record lint as untyped (`principle_untyped`, a warning), and +nothing backfills one. An entry carrying any of them carries all four +(`principle_claims`, blocking), and its `**The rule.**` paragraph carries no +record handle and no link, because a reading receives the H1 title and that +paragraph and nothing else. Evidence naming a scope condition is read against +the condition's standing disposition: falsified blocks (`principle_falsified`), +and narrowed, untested or unresolvable is reported (`principle_inheritance`). diff --git a/.abcd/record-lint.json b/.abcd/record-lint.json index 99c1ad66e..957987d9d 100644 --- a/.abcd/record-lint.json +++ b/.abcd/record-lint.json @@ -281,7 +281,8 @@ "rdg": ".abcd/development/readings", "adm": ".abcd/work/issues/admissions", "srp": ".abcd/work/issues/surprises", - "rfm": ".abcd/work/issues/reframes" + "rfm": ".abcd/work/issues/reframes", + "prn": ".abcd/development/principles" } }, "prose_citation_resolves": { @@ -304,6 +305,22 @@ "enabled": true, "severity": "blocker" }, + "principle_untyped": { + "enabled": true, + "severity": "warn" + }, + "principle_claims": { + "enabled": true, + "severity": "blocker" + }, + "principle_inheritance": { + "enabled": true, + "severity": "warn" + }, + "principle_falsified": { + "enabled": true, + "severity": "blocker" + }, "agent_contract": { "enabled": true, "severity": "blocker", diff --git a/internal/core/lint/config.go b/internal/core/lint/config.go index fcabb7dee..084f20bba 100644 --- a/internal/core/lint/config.go +++ b/internal/core/lint/config.go @@ -391,6 +391,10 @@ var knownRules = map[string]bool{ ruleProseCitationResolves: true, ruleReadingOutstanding: true, ruleRecordProvenance: true, + rulePrincipleUntyped: true, + rulePrincipleClaims: true, + rulePrincipleInheritance: true, + rulePrincipleFalsified: true, ruleRecordSchema: true, } diff --git a/internal/core/lint/duplicatekeyreaders_test.go b/internal/core/lint/duplicatekeyreaders_test.go index 1cd15fdd8..d9d3a38fe 100644 --- a/internal/core/lint/duplicatekeyreaders_test.go +++ b/internal/core/lint/duplicatekeyreaders_test.go @@ -163,6 +163,18 @@ func duplicateKeyReaderRows() []readerRow { reader: "record.Describe → describeReframe → readRecordHead → frontmatter.Fields", want: keepsFirst, probe: probeReframeDescribe, + }, { + // The principles family (spc-2609020626042471). The rules that read a + // principle's claims read it through this package's own scan, which keeps + // the first value. The reading assembler, the family's other reader, + // refuses a file carrying an EXCLUDED key twice — the four claim keys are + // excluded — and that refusal is established where the assembler lives + // (reading's TestDuplicateExcludedKeyRefusesTheFile), since this package + // cannot build the repository an assembly needs. + store: "prn", + reader: "lint principle_claims → scanRecordStores → frontmatter.Fields", + want: keepsFirst, + probe: probePrincipleClaims, }} } @@ -211,6 +223,7 @@ func TestThisRulesOwnScannerKeepsTheFirstValueInEveryStore(t *testing.T) { "work/issues/admissions/rdg-1/adm-3.md": "---\nschema_version: 1\nid: adm-3\nid: adm-404\nrun: rdg-1\nproposal: rdi-2\ngrounds: it widens the frame\n---\n\n", "work/issues/surprises/srp-6.md": "---\nschema_version: 1\nid: srp-6\nid: srp-404\noccasioned_by: rdi-2\n---\n\n", "work/issues/reframes/rfm-6.md": dupReframe("id: rfm-6\nid: rfm-404"), + "rec/principles/a-thing.md": "---\nid: prn-a-thing\nid: prn-404\n---\n\n# A thing\n", } writeRel(t, root, "rec/.keep", "") for rel, body := range files { @@ -565,6 +578,33 @@ func probeReframeDescribe(t *testing.T) answer { return which(t, d.Links["occasioned_by"], "FIRST-MARKER", "SECOND-MARKER") } +// probePrincipleClaims reads a typed principle whose claim_type is written +// twice through the principle rules, whose finding quotes the value they kept. +func probePrincipleClaims(t *testing.T) answer { + root := t.TempDir() + writeRel(t, root, "rec/principles/p.md", "---\nid: prn-p\nclaim_type: FIRST-MARKER\nclaim_type: SECOND-MARKER\n"+ + "reference: null\ncomparison: null\nevidence: [adr-1]\n---\n\n# P\n\n**The rule.** A rule.\n") + cfg := everyStoreConfig() + cfg.Rules["principle_claims"] = lint.RuleConfig{Enabled: true, Severity: "blocker"} + fs, err := lint.Lint(cfg, root) + if err != nil { + return refuses + } + // The rule quotes the value it judged; which marker that is, is the answer. + kept := "" + for _, f := range fs { + if f.RuleID != "principle_claims" { + continue + } + for _, m := range []string{"FIRST-MARKER", "SECOND-MARKER"} { + if strings.Contains(f.Message, "'"+m+"'") { + kept = m + } + } + } + return which(t, kept, "FIRST-MARKER", "SECOND-MARKER") +} + func probeUnread(t *testing.T, rel, body string) answer { root, _ := readingLedger(t, detectionItem) writeRel(t, root, rel, body) @@ -737,6 +777,7 @@ func everyStoreConfig() lint.Config { "adm": "work/issues/admissions", "srp": "work/issues/surprises", "rfm": "work/issues/reframes", + "prn": "rec/principles", }}, }, } diff --git a/internal/core/lint/graph.go b/internal/core/lint/graph.go index 6b6cd7007..32f7bd308 100644 --- a/internal/core/lint/graph.go +++ b/internal/core/lint/graph.go @@ -90,26 +90,14 @@ func LoadRecordGraph(cfg Config, repoRoot string) (RecordGraph, error) { return RecordGraph{}, err } - present := make(map[recordRef]bool, len(records)) - highWater := map[string]int{} + // present keys on the rendered handle, so a slug-keyed record (a principle, + // prn-<stem>) is present under the handle it is cited by rather than under a + // (prefix, 0) pair every principle would share. + present := make(map[string]bool, len(records)) for _, r := range records { - present[recordRef{r.store.prefix, r.num}] = true - if r.num > highWater[r.store.prefix] { - highWater[r.store.prefix] = r.num - } - } - var retired []string - seenRetired := map[recordRef]bool{} - for _, r := range records { - for _, h := range r.refs["supersedes"] { - if present[h] || seenRetired[h] || h.num < 1 || h.num > highWater[h.prefix] { - continue - } - seenRetired[h] = true - retired = append(retired, h.String()) - } + present[r.handle()] = true } - sort.Slice(retired, func(i, j int) bool { return HandleLess(retired[i], retired[j]) }) + retired := retiredHandles(records) g := RecordGraph{ Nodes: make([]RecordNode, 0, len(records)), @@ -142,7 +130,7 @@ func LoadRecordGraph(cfg Config, repoRoot string) (RecordGraph, error) { continue } seen[e] = true - if present[h] { + if present[h.String()] { g.Edges = append(g.Edges, e) } else { g.Dangling = append(g.Dangling, e) diff --git a/internal/core/lint/lint.go b/internal/core/lint/lint.go index 5819c8bc7..b4c597600 100644 --- a/internal/core/lint/lint.go +++ b/internal/core/lint/lint.go @@ -551,6 +551,15 @@ func LintAt(cfg Config, repoRoot string, now time.Time) ([]Finding, error) { findings = append(findings, ro...) } + // The four principle rules read the same cross-store scan, and one run + // serves all four so the principles store, the shipped intents' condition + // markers and the corpus's handles are read once (spc-2609020626042471). + pr, err := checkPrinciples(repoRoot, cfg) + if err != nil { + return nil, err + } + findings = append(findings, pr...) + // record_provenance reads the same cross-store scan record_schema walks, so // it runs once here rather than per root. if rpCfg, ok := cfg.Rules[ruleRecordProvenance]; ok && rpCfg.Enabled { diff --git a/internal/core/lint/principles.go b/internal/core/lint/principles.go new file mode 100644 index 000000000..a1fe05ae6 --- /dev/null +++ b/internal/core/lint/principles.go @@ -0,0 +1,480 @@ +package lint + +// The principles family's four rules (spc-2609020626042471, under +// adr-2609021016270132). +// +// The family is a declared record store (the `prn` entry in recordStores): slug +// keyed, flat, and walked by record_schema, which judges the one identity key a +// typed entry carries. What a principle CLAIMS is judged here, over four +// frontmatter keys an entry may declare — `claim_type`, `reference`, +// `comparison` and `evidence` — and four rules, two about the shape of the +// claims and two about what the evidence inherits: +// +// - principle_untyped (warn): an entry carrying none of the four. It is a +// count, not a fault: population is forward-only, nothing backfills an +// existing entry, and the count is expected to stay non-zero for some time. +// - principle_claims (blocker): a typed entry's defects — a key present while +// another is absent, a missing id, an empty value, a value outside its +// grammar, a duplicated evidence member, and a statement that cites. +// - principle_inheritance (warn): evidence resting on a narrowed or untested +// scope condition, or naming a record or condition this repository cannot +// resolve. +// - principle_falsified (blocker): evidence resting on a scope condition that +// delivery dispositioned as falsified — the one inheritance the disposition +// exists to prevent. +// +// Two rule ids for the shape and two for the inheritance, rather than one each, +// because a rule in the configuration carries ONE severity and no rule in this +// package emits another. +// +// Only a TYPED entry is judged by the three rules below principle_untyped; an +// untyped entry produces the untyped count and nothing else until its author +// types it. + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/intentdriven/abcd/internal/core/condition" + "github.com/intentdriven/abcd/internal/core/frontmatter" + "github.com/intentdriven/abcd/internal/core/mdrecord" + "github.com/intentdriven/abcd/internal/core/recordid" +) + +const ( + rulePrincipleUntyped = "principle_untyped" + rulePrincipleClaims = "principle_claims" + rulePrincipleInheritance = "principle_inheritance" + rulePrincipleFalsified = "principle_falsified" +) + +// principleStorePrefix is the principles family's record_stores key. +const principleStorePrefix = "prn" + +// PrincipleKeys are the four claim keys a principle may declare, in the order a +// finding lists them. +var PrincipleKeys = []string{"claim_type", "reference", "comparison", "evidence"} + +// PrincipleStatementLabel is the bold label that opens a principle's statement +// paragraph. The statement is the H1 title and this paragraph, and nothing +// after it; the reading assembler projects exactly that, and principle_claims +// refuses a typed statement carrying anything the projection could not keep. +const PrincipleStatementLabel = "The rule" + +// The claim vocabulary: the design documents' three words for the claim kinds +// intents carry, and the shipped intent token read as an alias for the causal +// kind (itd-177, itd-190). Nothing refuses the alias and nothing writes it. +const ( + ClaimCriterion = "criterion" + ClaimCausal = "causal" + ClaimContext = "context" + claimMechanism = "mechanism" +) + +// ClaimTypes is the closed vocabulary, in the order a refusal names it. +var ClaimTypes = []string{ClaimCriterion, ClaimCausal, ClaimContext} + +// CanonicalClaimType reads a claim_type value into the vocabulary: one of the +// three, or the alias read as causal. ok is false for anything else. +func CanonicalClaimType(v string) (string, bool) { + if v == claimMechanism { + return ClaimCausal, true + } + for _, c := range ClaimTypes { + if v == c { + return c, true + } + } + return "", false +} + +// principleNullity is the one spelling of a declined claim. It is the literal +// `null` alone, on claims.go's NullityToken precedent: every other spelling +// frontmatter.EmptinessOf folds (`~`, the case variants, a blank, `[]`, `""`) is +// the byte shape of a key someone forgot, and one spelling is what lets a gate +// tell a declined claim from a mistyped one. +const principleNullity = "null" + +var ( + // principleEvidenceHandleRe is the record-handle half of evidence's grammar: + // the families a principle distils, spelled lower case and unpadded. + principleEvidenceHandleRe = regexp.MustCompile(`^(adr|itd|spc|iss|rdi)-([0-9]+)$`) + // statementLinkRe finds a markdown inline link, whose target is a citation + // however its label reads. + statementLinkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)]*)\)`) +) + +// principleRules names the four rules in dispatch order. +var principleRules = []string{rulePrincipleUntyped, rulePrincipleClaims, rulePrincipleInheritance, rulePrincipleFalsified} + +// checkPrinciples runs every armed principle rule over one scan of the record +// stores. The stores are record_schema's, taken as record_provenance takes +// them: a principle rule naming its own record_stores wins, and otherwise the +// record_schema configuration is the declaration. A configuration naming no +// principles store has nothing to judge and contributes nothing. +func checkPrinciples(repoRoot string, cfg Config) ([]Finding, error) { + armed := map[string]RuleConfig{} + var stores map[string]string + for _, id := range principleRules { + rc, ok := cfg.Rules[id] + if !ok || !rc.Enabled { + continue + } + armed[id] = rc + if stores == nil && len(rc.RecordStores) > 0 { + stores = rc.RecordStores + } + } + if len(armed) == 0 { + return nil, nil + } + if stores == nil { + stores = cfg.Rules[ruleRecordSchema].RecordStores + } + if stores[principleStorePrefix] == "" { + return nil, nil + } + scanCfg := cfg.Rules[ruleRecordSchema] + scanCfg.RecordStores = stores + // The scan's own findings belong to record_schema, which reports them under + // its own rule id when it is armed. + records, _, err := scanRecordStores(repoRoot, scanCfg) + if err != nil { + return nil, err + } + + p := principleCheck{armed: armed, resolvable: resolvableHandles(records)} + if dir := stores["itd"]; dir != "" { + if p.conditions, err = shippedConditions(repoRoot, dir); err != nil { + return nil, err + } + } + var out []Finding + for _, r := range records { + if r.store.prefix != principleStorePrefix { + continue + } + fs, err := p.judge(repoRoot, r) + if err != nil { + return nil, err + } + out = append(out, fs...) + } + return out, nil +} + +// principleCheck is one run's state: which rules are armed, which handles the +// corpus resolves, and which shipped intents carry each scope condition. +type principleCheck struct { + armed map[string]RuleConfig + resolvable map[string]bool + conditions map[string][]condCarrier +} + +// condCarrier is one shipped intent carrying a condition marker. +type condCarrier struct { + rel string + content string +} + +// add appends a finding under rule if the rule is armed. +func (p principleCheck) add(out *[]Finding, rule string, r schemaRecord, line int, msg string) { + rc, ok := p.armed[rule] + if !ok { + return + } + if line == 0 { + line = 1 + } + *out = append(*out, Finding{File: r.rel, Line: line, RuleID: rule, Severity: rc.Severity, Message: msg}) +} + +// judge applies the four rules to one principle. +func (p principleCheck) judge(repoRoot string, r schemaRecord) ([]Finding, error) { + var out []Finding + h := r.handle() + present := []string{} + for _, k := range PrincipleKeys { + if _, ok := r.fields[k]; ok { + present = append(present, k) + } + } + if len(present) == 0 { + p.add(&out, rulePrincipleUntyped, r, 1, "principle "+h+" is untyped (carries none of "+ + strings.Join(PrincipleKeys, ", ")+")") + return out, nil + } + + claim := func(line int, msg string) { p.add(&out, rulePrincipleClaims, r, line, "principle "+h+": "+msg) } + + // All four, or the null. + for _, k := range PrincipleKeys { + if _, ok := r.fields[k]; !ok { + claim(1, "carries "+strings.Join(present, ", ")+" but not '"+k+"'; a typed principle states all four "+ + "claims or declines one with a bare `null`, and an absent key is a claim not carried") + } + } + + // The identity. A wrong id is record_schema's finding (the filename leg), so + // this leg speaks only to the id that is not there. + if f, ok := r.fields["id"]; !ok { + claim(1, "is typed and carries no 'id'; a typed principle states its handle, '"+h+"'") + } else if frontmatter.EmptinessOf(f.value) != frontmatter.Populated { + claim(f.line, "'id' carries no value; a typed principle states its handle, '"+h+"'") + } + + var evidence []string + for _, k := range PrincipleKeys { + f, ok := r.fields[k] + if !ok { + continue + } + raw := strings.TrimSpace(f.value) + if raw == principleNullity { + continue + } + if frontmatter.EmptinessOf(raw) != frontmatter.Populated { + if k == "evidence" && strings.TrimSpace(r.blocks[k]) != "" { + claim(f.line, "'evidence' is written as a block sequence; it is a flow sequence on the key's own "+ + "line, `evidence: [adr-N, cond-…]`, which is the one spelling the lint and the assembler read") + continue + } + claim(f.line, "'"+k+"' carries no value; a blank is the byte shape of a key someone forgot, and a "+ + "claim considered and declined is the literal `null` alone") + continue + } + switch k { + case "claim_type": + v, _ := frontmatter.ScalarString(raw) + if _, ok := CanonicalClaimType(v); !ok { + claim(f.line, "'claim_type' is '"+v+"', which is not a claim kind; the vocabulary is "+ + strings.Join(ClaimTypes, ", ")+" (mechanism is read as causal)") + } + case "reference": + if !validPrincipleReference(raw) { + claim(f.line, "'reference' is "+raw+", which is neither a record handle (adr-N, itd-N, spc-N, iss-N) nor a "+ + "double-quoted name of a surface") + } + case "comparison": + if !quotedSentence(raw) { + claim(f.line, "'comparison' is "+raw+", which is not a double-quoted sentence naming what was compared") + } + case "evidence": + members, ok := principleEvidence(raw) + if !ok { + claim(f.line, "'evidence' is not a flow sequence of record handles and scope-condition identities") + continue + } + seen := map[string]bool{} + for _, m := range members { + if seen[m] { + claim(f.line, "evidence names '"+m+"' twice") + continue + } + seen[m] = true + if !principleEvidenceHandleRe.MatchString(m) && !condition.MarkerIDRe.MatchString(m) { + claim(f.line, "evidence member '"+m+"' is neither a record handle (adr-N, itd-N, spc-N, iss-N, "+ + "rdi-N) nor a scope-condition identity (cond- and sixteen digits)") + continue + } + evidence = append(evidence, m) + } + } + } + + // The statement: the projection promises one free of genealogy, and a + // promise the assembler cannot keep is one this rule refuses first. + content, err := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(r.rel))) + if err != nil { + return nil, err + } + lines := strings.Split(string(content), "\n") + if start, end, ok := mdrecord.LabelledParagraph(lines, PrincipleStatementLabel); ok { + statement := strings.Join(lines[start:end], "\n") + if m := statementLinkRe.FindStringSubmatch(statement); m != nil { + claim(start+1, "the **"+PrincipleStatementLabel+".** paragraph carries a link to '"+m[1]+"'; the "+ + "statement travels to a reading as knowledge and its citations stay behind as genealogy, so the "+ + "link belongs in `evidence` or below the statement") + } else if id, ok := recordid.HandleInText(statement); ok { + claim(start+1, "the **"+PrincipleStatementLabel+".** paragraph carries the record handle '"+id+"'; the "+ + "statement travels to a reading as knowledge and its citations stay behind as genealogy, so the "+ + "handle belongs in `evidence` or below the statement") + } + } + + line := r.fields["evidence"].line + for _, m := range evidence { + p.inherit(&out, r, line, m) + } + return out, nil +} + +// inherit resolves one evidence member and reports what the principle inherits +// from it. +func (p principleCheck) inherit(out *[]Finding, r schemaRecord, line int, m string) { + h := r.handle() + if sub := principleEvidenceHandleRe.FindStringSubmatch(m); sub != nil { + n, err := strconv.Atoi(sub[2]) + if err == nil && p.resolvable[sub[1]+"-"+strconv.Itoa(n)] { + return + } + p.add(out, rulePrincipleInheritance, r, line, "principle "+h+": evidence names '"+m+"', which is "+ + "unresolvable in this repository's record stores; a principle distilled from a lifeboat cites packed "+ + "ids, which resolve only in the source repository") + return + } + carriers := p.conditions[m] + switch len(carriers) { + case 0: + p.add(out, rulePrincipleInheritance, r, line, "principle "+h+": evidence names '"+m+"', which is "+ + "unresolvable: no shipped intent carries that scope condition, so what it inherits cannot be read") + return + case 1: + default: + rels := make([]string, 0, len(carriers)) + for _, c := range carriers { + rels = append(rels, c.rel) + } + p.add(out, rulePrincipleInheritance, r, line, "principle "+h+": evidence names '"+m+"', which is "+ + "ambiguous: "+strconv.Itoa(len(carriers))+" shipped intents carry it ("+strings.Join(rels, ", ")+ + "), so which disposition stands cannot be read") + return + } + c := carriers[0] + d, ok := condition.Standing(c.content)[m] + switch { + case ok && d.Disposition == condition.Falsified: + msg := "principle " + h + " rests on scope condition " + m + " (" + c.rel + "), which delivery dispositioned " + + "as falsified" + if d.Rationale != "" { + msg += ": " + d.Rationale + } + p.add(out, rulePrincipleFalsified, r, line, msg+"; a principle inherits only what held, so restate its "+ + "evidence or the principle") + case ok && d.Disposition == condition.Narrowed: + p.add(out, rulePrincipleInheritance, r, line, "principle "+h+" rests on scope condition "+m+" ("+c.rel+ + "), which was dispositioned as narrowed: "+d.Narrowing) + case ok && d.Disposition == condition.Survived: + return + default: + why := "no disposition in its Audit Notes" + if ok { + why = "dispositioned untested" + } + p.add(out, rulePrincipleInheritance, r, line, "principle "+h+" rests on the untested condition "+m+" ("+ + c.rel+", "+why+")") + } +} + +// validPrincipleReference reports whether a reference is a bare record handle +// or a double-quoted, non-empty surface name. +func validPrincipleReference(raw string) bool { + if recordid.CitedIDRe.MatchString(raw) { + return true + } + return quotedSentence(raw) +} + +// quotedSentence reports whether raw is a double-quoted scalar carrying text. +func quotedSentence(raw string) bool { + if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' { + return false + } + v, ok := frontmatter.ScalarString(raw) + return ok && strings.TrimSpace(v) != "" +} + +// principleEvidence reads evidence's flow sequence into its members. +func principleEvidence(raw string) ([]string, bool) { + if !strings.HasPrefix(raw, "[") || !strings.HasSuffix(raw, "]") { + return nil, false + } + members := frontmatter.StringList(raw) + return members, len(members) > 0 +} + +// PrincipleEvidence is principleEvidence for a caller outside the lint: the one +// reader of evidence's grammar. +func PrincipleEvidence(raw string) ([]string, bool) { return principleEvidence(strings.TrimSpace(raw)) } + +// resolvableHandles is every handle the corpus resolves: each record's own, and +// each id a record declares it superseded within its store's allocation — the +// same bound record_schema and the record graph put on a retirement. +func resolvableHandles(records []schemaRecord) map[string]bool { + out := make(map[string]bool, len(records)) + for _, r := range records { + out[r.handle()] = true + } + for _, h := range retiredHandles(records) { + out[h] = true + } + return out +} + +// retiredHandles are the ids some record declares it superseded, bounded by each +// store's allocation high-water mark and absent from the corpus, sorted. +func retiredHandles(records []schemaRecord) []string { + present := make(map[recordRef]bool, len(records)) + highWater := map[string]int{} + for _, r := range records { + if r.store.slugKeyed { + continue + } + present[recordRef{r.store.prefix, r.num}] = true + if r.num > highWater[r.store.prefix] { + highWater[r.store.prefix] = r.num + } + } + var out []string + seen := map[recordRef]bool{} + for _, r := range records { + for _, h := range r.refs["supersedes"] { + if present[h] || seen[h] || h.num < 1 || h.num > highWater[h.prefix] { + continue + } + seen[h] = true + out = append(out, h.String()) + } + } + sort.Slice(out, func(i, j int) bool { return HandleLess(out[i], out[j]) }) + return out +} + +// shippedConditions indexes every scope-condition marker the shipped intents +// carry, by identity, each intent counted once per identity. It reads the +// markers with condition.MarkerRe, the one grammar the intent store writes. +func shippedConditions(repoRoot, intentsDir string) (map[string][]condCarrier, error) { + dir := filepath.Join(repoRoot, filepath.FromSlash(intentsDir), "shipped") + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + out := map[string][]condCarrier{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || !e.Type().IsRegular() { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return nil, err + } + content := string(data) + rel := filepath.ToSlash(filepath.Join(intentsDir, "shipped", e.Name())) + seen := map[string]bool{} + for _, m := range condition.MarkerRe.FindAllStringSubmatch(content, -1) { + if seen[m[1]] { + continue + } + seen[m[1]] = true + out[m[1]] = append(out[m[1]], condCarrier{rel: rel, content: content}) + } + } + return out, nil +} diff --git a/internal/core/lint/principles_test.go b/internal/core/lint/principles_test.go new file mode 100644 index 000000000..7e6b40d91 --- /dev/null +++ b/internal/core/lint/principles_test.go @@ -0,0 +1,450 @@ +package lint + +import ( + "path/filepath" + "strings" + "testing" +) + +// The principles family as a declared record store, and the four rules over its +// typed entries (spc-2609020626042471, adr-2609021016270132). + +const ( + prnDir = "rec/principles" + prnShipped = "rec/intents/shipped" + prnCondA = "cond-2609020626047525" + prnCondB = "cond-2609020626048283" + prnOccasion = "rdi-2609011200000001" +) + +// principleStores is schemaStores plus the principles family. +func principleStores() map[string]string { + s := schemaStores() + s["prn"] = prnDir + return s +} + +// principleConfig arms record_schema and the four principle rules at the +// severities the committed configuration declares. +func principleConfig() Config { + return Config{ + Roots: []string{"rec"}, + Rules: map[string]RuleConfig{ + ruleRecordSchema: {Enabled: true, Severity: severityBlocker, RecordStores: principleStores()}, + rulePrincipleUntyped: {Enabled: true, Severity: severityWarn}, + rulePrincipleClaims: {Enabled: true, Severity: severityBlocker}, + rulePrincipleInheritance: {Enabled: true, Severity: severityWarn}, + rulePrincipleFalsified: {Enabled: true, Severity: severityBlocker}, + }, + } +} + +// typedPrinciple renders a typed entry with the four keys given verbatim (an +// empty argument leaves the key out) and a statement paragraph. +func typedPrinciple(stem, claimType, reference, comparison, evidence, rule string) string { + var b strings.Builder + b.WriteString("---\nid: prn-" + stem + "\n") + for _, kv := range [][2]string{{"claim_type", claimType}, {"reference", reference}, {"comparison", comparison}, {"evidence", evidence}} { + if kv[1] == "" { + continue + } + b.WriteString(kv[0] + ": " + kv[1] + "\n") + } + b.WriteString("---\n\n# A principle\n\n**The rule.** " + rule + "\n\n**Why.** Because adr-1 said so.\n") + return b.String() +} + +// shippedIntent renders a shipped intent carrying one scope condition and the +// given Audit Notes blocks. +func shippedIntent(id string, conds []string, audit string) string { + var b strings.Builder + b.WriteString("---\nid: " + id + "\n---\n\n# A shipped intent\n\n## Scope Conditions\n\n") + for _, c := range conds { + b.WriteString("- A condition held. <!-- cond: " + c + " -->\n") + } + b.WriteString("\n## Audit Notes\n\n" + audit + "\n") + return b.String() +} + +func conditionBlock(id, value, rationale, narrowing string) string { + s := "<!-- abcd-condition: " + id + " occasion=" + prnOccasion + " -->\n" + + "Condition disposition — 2026-09-02, occasioned by " + prnOccasion + ".\n" + + "- " + id + " — " + value + ": " + rationale + "\n" + if narrowing != "" { + s += " narrowing: " + narrowing + "\n" + } + return s +} + +func lintPrinciples(t *testing.T, root string) []Finding { + t.Helper() + fs, err := Lint(principleConfig(), root) + if err != nil { + t.Fatal(err) + } + return fs +} + +func rulesOf(fs []Finding, file string) []string { + var out []string + for _, f := range fs { + if f.File == file { + out = append(out, f.RuleID+": "+f.Message) + } + } + return out +} + +// TestPrincipleStoreIsSlugKeyed: the handle is prn-<filename stem>, the id a +// typed entry carries is compared against it, and the record graph carries the +// entry as a principle node under that handle. +func TestPrincipleStoreIsSlugKeyed(t *testing.T) { + root := t.TempDir() + writeFile(t, root, prnDir+"/fix-the-detector.md", + typedPrinciple("fix-the-detector", "causal", `"abcd lint"`, `"Hand fixes against a detector."`, "[adr-1]", "Fix the class.")) + writeFile(t, root, prnDir+"/wrong-id.md", + strings.Replace(typedPrinciple("wrong-id", "causal", `"abcd lint"`, `"One against another."`, "[adr-1]", "Fix."), "id: prn-wrong-id", "id: prn-something-else", 1)) + writeFile(t, root, "rec/decisions/adrs/0001-a.md", "---\nid: adr-1\n---\n# ADR-1\n") + + fs := lintPrinciples(t, root) + if got := rulesOf(fs, filepath.Join(prnDir, "fix-the-detector.md")); len(got) != 0 { + t.Errorf("a well-typed principle drew findings: %v", got) + } + if !findingWith(fs, filepath.Join(prnDir, "wrong-id.md"), ruleRecordSchema, "'prn-wrong-id'") { + t.Errorf("an id disagreeing with prn-<stem> is not reported by record_schema: %+v", fs) + } + + g, err := LoadRecordGraph(principleConfig(), root) + if err != nil { + t.Fatal(err) + } + found := false + for _, n := range g.Nodes { + if n.ID == "prn-fix-the-detector" { + found = true + if n.Type != "principle" || n.Title != "A principle" { + t.Errorf("the principle node is %+v, want type principle titled by its H1", n) + } + } + } + if !found { + t.Errorf("the record graph carries no prn-fix-the-detector node: %+v", g.Nodes) + } +} + +// TestSlugKeyedStoreCollidesOnTheRenderedHandle: uniqueness keys on the rendered +// handle, so thirty untyped principles are thirty handles and not thirty claims +// on prn-0. +func TestSlugKeyedStoreCollidesOnTheRenderedHandle(t *testing.T) { + root := t.TempDir() + for _, stem := range []string{"one", "two", "three"} { + writeFile(t, root, prnDir+"/"+stem+".md", "# "+stem+"\n\n**The rule.** A rule.\n") + } + fs := lintPrinciples(t, root) + for _, f := range fs { + if f.RuleID == ruleRecordSchema { + t.Errorf("a clean slug-keyed store drew a record_schema finding: %+v", f) + } + } +} + +// TestUntypedPrincipleHasNoSchemaFinding: an entry with no frontmatter is a +// prose file keyed by its filename, which is what every entry is today. +func TestUntypedPrincipleHasNoSchemaFinding(t *testing.T) { + root := t.TempDir() + writeFile(t, root, prnDir+"/less-but-better.md", "# Less, but better\n\n**The rule.** Fewer things, done well.\n") + writeFile(t, root, prnDir+"/README.md", "# principles/\n\nThe store's own index.\n") + fs := lintPrinciples(t, root) + if n := countRule(fs, ruleRecordSchema); n != 0 { + t.Errorf("an untyped principle drew %d record_schema finding(s): %+v", n, fs) + } +} + +// TestUntypedPrincipleIsAWarnAndNothingElse is ac-2. +func TestUntypedPrincipleIsAWarnAndNothingElse(t *testing.T) { + root := t.TempDir() + rel := filepath.Join(prnDir, "less-but-better.md") + writeFile(t, root, prnDir+"/less-but-better.md", "# Less, but better\n\n**The rule.** See [adr-9](x.md).\n") + fs := lintPrinciples(t, root) + got := rulesOf(fs, rel) + if len(got) != 1 { + t.Fatalf("an untyped principle drew %d findings, want exactly the one untyped warning: %v", len(got), got) + } + f := fs[0] + for _, x := range fs { + if x.File == rel { + f = x + } + } + if f.RuleID != rulePrincipleUntyped || f.Severity != severityWarn { + t.Errorf("the finding is %s at %s, want %s at warn", f.RuleID, f.Severity, rulePrincipleUntyped) + } + want := "principle prn-less-but-better is untyped (carries none of claim_type, reference, comparison, evidence)" + if f.Message != want { + t.Errorf("message = %q, want %q", f.Message, want) + } +} + +// TestPrincipleClaimsNamesTheMissingKey is ac-1. +func TestPrincipleClaimsNamesTheMissingKey(t *testing.T) { + root := t.TempDir() + writeFile(t, root, prnDir+"/partial.md", typedPrinciple("partial", "causal", `"abcd lint"`, "", "[adr-1]", "A rule.")) + writeFile(t, root, "rec/decisions/adrs/0001-a.md", "---\nid: adr-1\n---\n# ADR-1\n") + fs := lintPrinciples(t, root) + rel := filepath.Join(prnDir, "partial.md") + if !findingWith(fs, rel, rulePrincipleClaims, "'comparison'") { + t.Fatalf("a typed principle missing comparison is not reported naming the key: %v", rulesOf(fs, rel)) + } + for _, f := range fs { + if f.File == rel && f.RuleID == rulePrincipleClaims && f.Severity != severityBlocker { + t.Errorf("principle_claims fired at %s, want blocker", f.Severity) + } + if f.File == rel && f.RuleID == rulePrincipleUntyped { + t.Errorf("a partially typed principle is reported as untyped: %s", f.Message) + } + } +} + +// TestPrincipleClaimsRefusesEmptyValue: a blank value is the byte shape of a key +// someone forgot, and only the literal null declines a claim. +func TestPrincipleClaimsRefusesEmptyValue(t *testing.T) { + for name, tc := range map[string]struct{ key, value string }{ + "blank claim_type": {"claim_type", ""}, + "tilde reference": {"reference", "~"}, + "NULL comparison": {"comparison", "NULL"}, + "empty evidence": {"evidence", "[]"}, + "quoted empty": {"comparison", `""`}, + "blank id": {"id", ""}, + "capitalised null": {"claim_type", "Null"}, + "empty flow string": {"reference", `''`}, + } { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + body := typedPrinciple("p", "causal", `"abcd lint"`, `"One against another."`, "[adr-1]", "A rule.") + lines := strings.Split(body, "\n") + for i, l := range lines { + if strings.HasPrefix(l, tc.key+":") { + lines[i] = strings.TrimRight(tc.key+": "+tc.value, " ") + } + } + writeFile(t, root, prnDir+"/p.md", strings.Join(lines, "\n")) + writeFile(t, root, "rec/decisions/adrs/0001-a.md", "---\nid: adr-1\n---\n# ADR-1\n") + fs := lintPrinciples(t, root) + if !findingWith(fs, filepath.Join(prnDir, "p.md"), rulePrincipleClaims, "'"+tc.key+"'") { + t.Errorf("%s: %s: %q is not refused: %v", name, tc.key, tc.value, rulesOf(fs, filepath.Join(prnDir, "p.md"))) + } + }) + } + + // The explicit null alone is a declined claim, not a fault. + root := t.TempDir() + writeFile(t, root, prnDir+"/p.md", typedPrinciple("p", "null", "null", "null", "[adr-1]", "A rule.")) + writeFile(t, root, "rec/decisions/adrs/0001-a.md", "---\nid: adr-1\n---\n# ADR-1\n") + fs := lintPrinciples(t, root) + if got := rulesOf(fs, filepath.Join(prnDir, "p.md")); len(got) != 0 { + t.Errorf("a principle declining three claims with null drew findings: %v", got) + } +} + +// TestPrincipleClaimsRefusesFourthClaimType: the vocabulary is the three claim +// kinds, and a fourth is a ruling this rule does not make. +func TestPrincipleClaimsRefusesFourthClaimType(t *testing.T) { + root := t.TempDir() + writeFile(t, root, prnDir+"/p.md", typedPrinciple("p", "normative", `"abcd lint"`, `"One against another."`, "[adr-1]", "A rule.")) + writeFile(t, root, "rec/decisions/adrs/0001-a.md", "---\nid: adr-1\n---\n# ADR-1\n") + fs := lintPrinciples(t, root) + if !findingWith(fs, filepath.Join(prnDir, "p.md"), rulePrincipleClaims, "criterion, causal, context") { + t.Errorf("a fourth claim type is not refused naming the three: %v", rulesOf(fs, filepath.Join(prnDir, "p.md"))) + } +} + +// TestPrincipleClaimsReadsMechanismAsCausal: the shipped intent token is an +// alias on read, never refused with a rename. +func TestPrincipleClaimsReadsMechanismAsCausal(t *testing.T) { + root := t.TempDir() + writeFile(t, root, prnDir+"/p.md", typedPrinciple("p", "mechanism", `"abcd lint"`, `"One against another."`, "[adr-1]", "A rule.")) + writeFile(t, root, "rec/decisions/adrs/0001-a.md", "---\nid: adr-1\n---\n# ADR-1\n") + fs := lintPrinciples(t, root) + if got := rulesOf(fs, filepath.Join(prnDir, "p.md")); len(got) != 0 { + t.Errorf("claim_type: mechanism drew findings: %v", got) + } +} + +// TestPrincipleClaimsJudgesTheGrammar covers the three remaining grammars: a +// reference that is neither a handle nor a quoted surface, an evidence member +// that is neither a handle nor a condition identity, and a duplicated member. +func TestPrincipleClaimsJudgesTheGrammar(t *testing.T) { + for name, tc := range map[string]struct{ reference, evidence, want string }{ + "bare prose reference": {"abcd lint", "[adr-1]", "'reference'"}, + "prose evidence member": {`"abcd lint"`, "[adr-1, the ADR]", "'the ADR'"}, + "duplicate member": {`"abcd lint"`, "[adr-1, adr-1]", "twice"}, + "block sequence": {`"abcd lint"`, "", "'evidence'"}, + } { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + ev := tc.evidence + body := typedPrinciple("p", "context", tc.reference, `"One against another."`, ev, "A rule.") + if ev == "" { + body = strings.Replace(body, "---\n\n# A", "evidence:\n - adr-1\n---\n\n# A", 1) + } + writeFile(t, root, prnDir+"/p.md", body) + writeFile(t, root, "rec/decisions/adrs/0001-a.md", "---\nid: adr-1\n---\n# ADR-1\n") + fs := lintPrinciples(t, root) + if !findingWith(fs, filepath.Join(prnDir, "p.md"), rulePrincipleClaims, tc.want) { + t.Errorf("%s: no principle_claims finding quoting %s: %v", name, tc.want, rulesOf(fs, filepath.Join(prnDir, "p.md"))) + } + }) + } +} + +// TestPrincipleStatementMayNotCite: the projection promises a statement free of +// genealogy, so a typed statement carrying a record handle or a link is refused +// before the assembler has to. +func TestPrincipleStatementMayNotCite(t *testing.T) { + for name, rule := range map[string]string{ + "record handle": "Fix the class, as adr-1 ruled.", + "markdown link": "Fix the class, as [the ruling](../decisions/adrs/0001-a.md) says.", + "condition": "Fix the class while " + prnCondA + " holds.", + } { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + writeFile(t, root, prnDir+"/p.md", typedPrinciple("p", "causal", `"abcd lint"`, `"One against another."`, "[adr-1]", rule)) + writeFile(t, root, "rec/decisions/adrs/0001-a.md", "---\nid: adr-1\n---\n# ADR-1\n") + fs := lintPrinciples(t, root) + if !findingWith(fs, filepath.Join(prnDir, "p.md"), rulePrincipleClaims, "The rule.") { + t.Errorf("%s in the statement is not refused: %v", name, rulesOf(fs, filepath.Join(prnDir, "p.md"))) + } + }) + } +} + +// inheritanceCorpus writes one typed principle resting on prnCondA, and one +// shipped intent carrying that condition with the given audit block. +func inheritanceCorpus(t *testing.T, audit string) string { + t.Helper() + root := t.TempDir() + writeFile(t, root, prnDir+"/p.md", typedPrinciple("p", "causal", "itd-181", `"One against another."`, "[itd-181, "+prnCondA+"]", "A rule.")) + writeFile(t, root, prnShipped+"/itd-181-a.md", shippedIntent("itd-181", []string{prnCondA}, audit)) + return root +} + +// TestFalsifiedConditionIsReported is ac-3. +func TestFalsifiedConditionIsReported(t *testing.T) { + root := inheritanceCorpus(t, conditionBlock(prnCondA, "falsified", "delivery refuted it", "")) + fs := lintPrinciples(t, root) + rel := filepath.Join(prnDir, "p.md") + if !findingWith(fs, rel, rulePrincipleFalsified, prnCondA) || !findingWith(fs, rel, rulePrincipleFalsified, "prn-p") { + t.Fatalf("a principle resting on a falsified condition is not reported naming both: %v", rulesOf(fs, rel)) + } + for _, f := range fs { + if f.RuleID == rulePrincipleFalsified && f.Severity != severityBlocker { + t.Errorf("principle_falsified fired at %s, want blocker", f.Severity) + } + } +} + +// TestNarrowedConditionCarriesTheNarrowing is ac-4. +func TestNarrowedConditionCarriesTheNarrowing(t *testing.T) { + root := inheritanceCorpus(t, conditionBlock(prnCondA, "narrowed", "only half holds", "holds for one repository only")) + fs := lintPrinciples(t, root) + rel := filepath.Join(prnDir, "p.md") + if !findingWith(fs, rel, rulePrincipleInheritance, "holds for one repository only") { + t.Fatalf("a principle resting on a narrowed condition does not carry the narrowing: %v", rulesOf(fs, rel)) + } + if countRule(fs, rulePrincipleFalsified) != 0 { + t.Errorf("a narrowed condition drew a principle_falsified finding: %v", rulesOf(fs, rel)) + } +} + +// TestUndispositionedConditionIsUntested is ac-5, with the two spellings of no +// judgement: no disposition at all, and one recorded as untested. +func TestUndispositionedConditionIsUntested(t *testing.T) { + for name, audit := range map[string]string{ + "no disposition": "_Empty._", + "untested recorded": conditionBlock(prnCondA, "untested", "no reading reached it", ""), + "another condition": conditionBlock(prnCondB, "survived", "held", ""), + "survived elsewhere": "", + } { + t.Run(name, func(t *testing.T) { + root := inheritanceCorpus(t, audit) + fs := lintPrinciples(t, root) + if !findingWith(fs, filepath.Join(prnDir, "p.md"), rulePrincipleInheritance, "untested condition "+prnCondA) { + t.Errorf("%s: the principle is not reported as resting on an untested condition: %v", + name, rulesOf(fs, filepath.Join(prnDir, "p.md"))) + } + }) + } + + // A survived condition is what held, and is silent. + root := inheritanceCorpus(t, conditionBlock(prnCondA, "survived", "held", "")) + fs := lintPrinciples(t, root) + if got := rulesOf(fs, filepath.Join(prnDir, "p.md")); len(got) != 0 { + t.Errorf("a principle resting on a survived condition drew findings: %v", got) + } +} + +// TestUnresolvableEvidenceIsReportedNotAbsent: a principle distilled from a +// lifeboat cites packed ids, which resolve only in the source repository, and a +// condition identity no shipped intent carries is in the same position. +func TestUnresolvableEvidenceIsReportedNotAbsent(t *testing.T) { + root := t.TempDir() + writeFile(t, root, prnDir+"/p.md", typedPrinciple("p", "context", "adr-9", `"One against another."`, "[adr-9, "+prnCondB+"]", "A rule.")) + fs := lintPrinciples(t, root) + rel := filepath.Join(prnDir, "p.md") + for _, id := range []string{"adr-9", prnCondB} { + if !findingWith(fs, rel, rulePrincipleInheritance, "'"+id+"', which is unresolvable") { + t.Errorf("%s is not reported as unresolvable: %v", id, rulesOf(fs, rel)) + } + } + for _, f := range fs { + if f.File == rel && strings.Contains(f.Message, "absent") { + t.Errorf("unresolvable evidence is reported as absent: %s", f.Message) + } + } + + // Carried by two shipped intents: ambiguous. + writeFile(t, root, prnShipped+"/itd-1-a.md", shippedIntent("itd-1", []string{prnCondB}, "")) + writeFile(t, root, prnShipped+"/itd-2-b.md", shippedIntent("itd-2", []string{prnCondB}, "")) + fs = lintPrinciples(t, root) + if !findingWith(fs, rel, rulePrincipleInheritance, "ambiguous") { + t.Errorf("a condition two shipped intents carry is not reported as ambiguous: %v", rulesOf(fs, rel)) + } +} + +// TestWarnRuleDoesNotFailPreflight: the committed configuration arms the four +// rules at the severities the spec declares, and a tree whose only principle +// findings are the untyped count carries no blocker — record-lint fails on +// blockers alone, so the count is reported and never a wall. +func TestWarnRuleDoesNotFailPreflight(t *testing.T) { + cfg, err := LoadConfig(filepath.Join(repoRootFromPackage, ".abcd", "record-lint.json")) + if err != nil { + t.Fatal(err) + } + for rule, want := range map[string]string{ + rulePrincipleUntyped: severityWarn, + rulePrincipleClaims: severityBlocker, + rulePrincipleInheritance: severityWarn, + rulePrincipleFalsified: severityBlocker, + } { + rc, ok := cfg.Rules[rule] + if !ok || !rc.Enabled || rc.Severity != want { + t.Errorf("the committed config arms %s as %+v, want enabled at %s", rule, rc, want) + } + } + if got := cfg.Rules[ruleRecordSchema].RecordStores["prn"]; got != ".abcd/development/principles" { + t.Errorf("record_schema declares the prn store at %q", got) + } + + root := t.TempDir() + writeFile(t, root, prnDir+"/a.md", "# A\n\n**The rule.** A.\n") + writeFile(t, root, prnDir+"/b.md", "# B\n\n**The rule.** B.\n") + fs := lintPrinciples(t, root) + if n := countRule(fs, rulePrincipleUntyped); n != 2 { + t.Errorf("two untyped principles drew %d untyped finding(s), want 2", n) + } + for _, f := range fs { + if f.Severity == severityBlocker { + t.Errorf("an untyped-only store drew a blocker: %+v", f) + } + } +} diff --git a/internal/core/lint/schema.go b/internal/core/lint/schema.go index 84c2106f0..9e1cd094c 100644 --- a/internal/core/lint/schema.go +++ b/internal/core/lint/schema.go @@ -94,6 +94,10 @@ var ( // The reframe store is flat for the surprise store's reason: a reframe is // keyed by the occasion and the fingerprints it carries (spc-2609020626048705). reframeFileNumRe = recordid.FilenameNumRe(issueschema.ReframeFamily) + // The principles family is SLUG-KEYED (adr-2609021016270132): a principle's + // filename stem is its identity and it carries no ordinal, so the pattern + // captures the whole kebab stem where the numbered stores capture a number. + principleFileRe = regexp.MustCompile(`^([a-z0-9]+(?:-[a-z0-9]+)*)\.md$`) // A YAML block-scalar header and nothing else: `|`, `>`, with the chomping and // indentation indicators the spelling allows (`|-`, `>+`, `|2-`). A key // carrying one holds its value on the lines BELOW it, so the same-line scanner @@ -242,6 +246,13 @@ type recordStore struct { // field, so a disagreement is the record contradicting itself about which set // it joined. Empty means the store makes no such double claim. bucketField string + // slugKeyed declares a store whose records are keyed by their filename stem + // rather than by an ordinal: the handle is `<prefix>-<stem>`, fileNumRe's + // submatch 1 is the stem, and the store issues no number and so has no + // allocation high-water mark. The principles family is the one such store + // (adr-2609021016270132): its entries are prose files keyed by filename, and + // minting ordinals for them would cost every existing reference its handle. + slugKeyed bool } // recordJoin is one keying field a store declares, with what the join is FOR — @@ -439,6 +450,14 @@ var recordStores = []recordStore{ why: "a reframe is keyed to the reading record that occasioned it, and a join naming nothing joins nothing", oneOf: issueschema.ReframeOccasionFamilies, }}}, + // The principles family (adr-2609021016270132, spc-2609020626042471): flat, + // slug-keyed, and declaring no required set here, because an untyped entry — + // a prose file with no frontmatter, which is every entry the family held when + // it was declared — is a legal state. What a typed entry must carry is judged + // by principle_claims (principles.go), which reads the four claim keys this + // rule does not; the one key this rule judges is the id, against the stem. + {prefix: "prn", noun: "principle", nodeType: "principle", + fileNumRe: principleFileRe, filename: "<slug>.md", slugKeyed: true}, } // storeByPrefix returns the code-side store for a prefix. @@ -465,9 +484,12 @@ func recordStorePrefixes() map[string]bool { // schemaRecord is one record file as the schema rule sees it: which store and // bucket hold it, the id number its FILENAME claims, and its frontmatter. type schemaRecord struct { - rel string - store recordStore - num int + rel string + store recordStore + num int + // slug is the filename stem of a record in a slug-keyed store, and empty for + // every numbered one; num is zero where slug is set. + slug string bucket string // title is the record's H1, or — for a store whose records carry none, the // issue ledger — its first body line. The schema rule never reads it; it is @@ -490,8 +512,12 @@ type schemaRecord struct { blocks map[string]string } -// handle renders the record's prose handle (adr-12, itd-47). +// handle renders the record's prose handle (adr-12, itd-47), or for a +// slug-keyed store the prefix and the filename stem (prn-fix-the-detector). func (r schemaRecord) handle() string { + if r.store.slugKeyed { + return r.store.prefix + "-" + r.slug + } return r.store.prefix + "-" + strconv.Itoa(r.num) } @@ -542,10 +568,18 @@ func checkRecordSchema(repoRoot string, cfg RuleConfig) ([]Finding, error) { // (iss-2608270908346940). For the prose-handle stores the id-unique rules // (issue_id_unique, intent_lifecycle, spec_id_unique) catch the frontmatter-id // collision; the ADR store has no such rule, so this is its only guard. - index := map[recordRef]schemaRecord{} + // + // The index keys on the RENDERED handle, not on (prefix, ordinal): a + // slug-keyed store issues no ordinal, so keying on the pair would read every + // principle as prn-0 and report a clean store as thirty collisions. For a + // numbered store the rendered handle is the prefix-N string the pair spelled, + // so nothing about those stores moves; for the slug-keyed store it is + // prn-<stem>, which a filename-keyed directory can only hold once, so the leg + // reports nothing there today and stands guard over a second such store. + index := map[string]schemaRecord{} highWater := map[string]int{} for _, r := range records { - ref := recordRef{r.store.prefix, r.num} + ref := r.handle() if first, dup := index[ref]; dup { out = append(out, Finding{ File: r.rel, Line: 1, RuleID: ruleRecordSchema, Severity: cfg.Severity, @@ -555,7 +589,8 @@ func checkRecordSchema(repoRoot string, cfg RuleConfig) ([]Finding, error) { } else { index[ref] = r } - if r.num > highWater[r.store.prefix] { + // A slug-keyed store issues no ordinals and so has no high-water mark. + if !r.store.slugKeyed && r.num > highWater[r.store.prefix] { highWater[r.store.prefix] = r.num } } @@ -614,7 +649,7 @@ func checkRecordSchema(repoRoot string, cfg RuleConfig) ([]Finding, error) { for _, field := range recordRefFields { f := r.fields[field] for _, h := range r.refs[field] { - if _, ok := index[h]; ok || retired[h] { + if _, ok := index[h.String()]; ok || retired[h] { continue } add(r.rel, f.line, field+" names '"+h.String()+"', which is not a record in the corpus and no record declares it superseded; a cross-reference is a claim that the record exists") @@ -626,7 +661,7 @@ func checkRecordSchema(repoRoot string, cfg RuleConfig) ([]Finding, error) { // resolvable everywhere else in the corpus. sup := r.fields["supersedes"] for _, h := range r.refs["supersedes"] { - if _, ok := index[h]; ok { + if _, ok := index[h.String()]; ok { continue } if h.num >= 1 && h.num <= highWater[h.prefix] { @@ -648,7 +683,7 @@ func checkRecordSchema(repoRoot string, cfg RuleConfig) ([]Finding, error) { continue } for _, h := range targets { - target, ok := index[h] + target, ok := index[h.String()] if !ok { add(r.rel, sb.line, "superseded_by names '"+h.String()+"', which is not a record in the corpus; a successor decision must be present") continue @@ -666,7 +701,7 @@ func checkRecordSchema(repoRoot string, cfg RuleConfig) ([]Finding, error) { for _, r := range records { sup := r.fields["supersedes"] for _, h := range r.refs["supersedes"] { - target, ok := index[h] + target, ok := index[h.String()] if !ok { continue } @@ -698,6 +733,23 @@ func checkRecordFilename(r schemaRecord, severity string, judged map[string]bool } want := r.handle() got := issueScalar(f.value) + // A slug-keyed store's handle carries the stem verbatim, so it is compared as + // the string it is: there is no ordinal to parse and no padding to forgive. + if r.store.slugKeyed { + if got == want { + return nil + } + line := f.line + if line == 0 { + line = 1 + } + mark(judged, "id") + return []Finding{{ + File: r.rel, Line: line, RuleID: ruleRecordSchema, Severity: severity, + Message: "filename claims id '" + want + "' but frontmatter declares '" + got + + "'; a " + r.noun() + " is keyed by its filename, so its id is '" + r.store.prefix + "-' and the stem of " + r.store.filename, + }} + } // Compared as a PARSED handle, not as a string: `adr-0012` and `adr-12` are one // id written two ways (the rest of the rule already compares numerically), and // a string comparison would report the record's own zero-padded spelling as a @@ -768,6 +820,11 @@ func mark(judged map[string]bool, field string) { // filename grammar to match belongs on that record, because it changes what the // gate refuses across all four stores. func checkRecordFilenameSlug(r schemaRecord, severity string, judged map[string]bool) []Finding { + // A slug-keyed store's filename IS its slug and it carries no slug property to + // disagree with; its one identity question is the id, asked above. + if r.store.slugKeyed { + return nil + } f := r.fields["slug"] // isNull, not isAbsentValue, for checkRecordFilename's reason: an empty slug // is a value that disagrees, and the stores that would otherwise catch it @@ -973,7 +1030,7 @@ func checkRecordUnknownFields(r schemaRecord, severity string) []Finding { // (oneOf), so a prose occasion is a finding (spc-2609020626040342). A handle a record declares it PRUNED is resolved // too, on the same terms the cross-reference loop resolves it, so one rule gives // one answer about it. -func checkRecordJoins(r schemaRecord, index map[recordRef]schemaRecord, retired map[recordRef]bool, cfg RuleConfig) []Finding { +func checkRecordJoins(r schemaRecord, index map[string]schemaRecord, retired map[recordRef]bool, cfg RuleConfig) []Finding { var out []Finding for _, join := range r.store.joins { f := r.fields[join.field] @@ -1034,7 +1091,7 @@ func checkRecordJoins(r schemaRecord, index map[recordRef]schemaRecord, retired continue } ref := recordRef{prefix, num} - target, ok := index[ref] + target, ok := index[ref.String()] if !ok { // A handle a record declares it PRUNED resolves to that declaration rather // than to a file, exactly as the cross-reference loop in checkRecordSchema @@ -1534,9 +1591,15 @@ func scanRecordStores(repoRoot string, cfg RuleConfig) ([]schemaRecord, []Findin "); the filename is the handle every cross-reference resolves through") continue } - num, err := strconv.Atoi(m[1]) - if err != nil { - continue + num, slug := 0, "" + if store.slugKeyed { + slug = m[1] + } else { + n, err := strconv.Atoi(m[1]) + if err != nil { + continue + } + num = n } content, err := os.ReadFile(filepath.Join(bucketAbs, e.Name())) if err != nil { @@ -1597,6 +1660,7 @@ func scanRecordStores(repoRoot string, cfg RuleConfig) ([]schemaRecord, []Findin rel: rel, store: store, num: num, + slug: slug, bucket: bucket, title: recordTitle(lines), fields: fields, diff --git a/internal/core/mdrecord/labelled_test.go b/internal/core/mdrecord/labelled_test.go new file mode 100644 index 000000000..32c97d37b --- /dev/null +++ b/internal/core/mdrecord/labelled_test.go @@ -0,0 +1,41 @@ +package mdrecord + +import ( + "strings" + "testing" +) + +// TestLabelledParagraphFindsTheFirstLiveStatement pins the one reading of a +// labelled paragraph the principles lint and the reading assembler share +// (spc-2609020626042471): the first live paragraph opening with the label, to +// the next blank line, never one inside a fence or continuing a paragraph. +func TestLabelledParagraphFindsTheFirstLiveStatement(t *testing.T) { + doc := strings.Join([]string{ + "# A principle", // 0 + "", + "```", + "**The rule.** A fenced example, never the statement.", // 3 + "```", + "", + "Prose that mentions", + "**The rule.** mid-paragraph, which is not a paragraph of its own.", // 7 + "", + "**The rule.** The statement,", // 9 + "over two lines.", + "", + "**Why.** The reasons.", + "", + "**The rule.** A second statement is never read.", + }, "\n") + lines := strings.Split(doc, "\n") + start, end, ok := LabelledParagraph(lines, "The rule") + if !ok || start != 9 || end != 11 { + t.Fatalf("LabelledParagraph = %d, %d, %v; want 9, 11, true", start, end, ok) + } + if _, _, ok := LabelledParagraph(lines, "Bounds"); ok { + t.Error("a label the document does not carry was found") + } + if s, e, ok := LabelledParagraph(lines, "Why"); !ok || s != 12 || e != 13 { + t.Errorf("the Why paragraph = %d, %d, %v; want 12, 13, true", s, e, ok) + } +} diff --git a/internal/core/mdrecord/mdrecord.go b/internal/core/mdrecord/mdrecord.go index 7df648f01..6a5197438 100644 --- a/internal/core/mdrecord/mdrecord.go +++ b/internal/core/mdrecord/mdrecord.go @@ -379,3 +379,43 @@ func InAnyRange(ranges [][2]int, pos int) bool { } return false } + +// LabelledParagraph returns the [start, end) line bounds of the first live +// paragraph whose first line opens with the bold label `**<label>.**`, and +// whether one exists. The paragraph runs to the next blank line, the next live +// heading, the next masked line, or the end of the file, whichever is first. +// +// It is the one notion of a labelled paragraph, shared by the record lint that +// judges a principle's statement and the reading assembler that projects it +// (spc-2609020626042471): a principle carries one heading, its H1, and a body +// of labelled paragraphs (`**The rule.**`, `**Why.**`, `**Bounds.**`), so its +// statement is found by label rather than by heading, and two readers of that +// label that disagree are a gate refusing a paragraph the projection never +// sends, or the reverse. +// +// A label inside a fence or an HTML comment is an example, not a statement, and +// is never matched. The label must open the line: a paragraph that mentions +// `**The rule.**` part-way through is not the rule. +func LabelledParagraph(lines []string, label string) (start, end int, ok bool) { + mask := Mask(lines) + opener := "**" + label + ".**" + for i, ln := range lines { + if masked(mask, i) || !strings.HasPrefix(strings.TrimRight(ln, "\r"), opener) { + continue + } + // The paragraph must BEGIN here: a line directly continuing a previous + // paragraph is inside that paragraph, whatever it opens with. + if i > 0 && !masked(mask, i-1) && strings.TrimSpace(lines[i-1]) != "" && !IsHeading(lines[i-1]) { + continue + } + end = len(lines) + for j := i + 1; j < len(lines); j++ { + if masked(mask, j) || strings.TrimSpace(lines[j]) == "" || IsHeading(strings.TrimRight(lines[j], "\r")) { + end = j + break + } + } + return i, end, true + } + return 0, 0, false +} diff --git a/internal/core/recordid/handleintext_test.go b/internal/core/recordid/handleintext_test.go new file mode 100644 index 000000000..9847e4834 --- /dev/null +++ b/internal/core/recordid/handleintext_test.go @@ -0,0 +1,34 @@ +package recordid + +import "testing" + +// TestHandleInTextFindsGenealogy pins what counts as a citation inside a +// principle's statement (spc-2609020626042471): any numbered record family or a +// condition identity as a whole token in any case, and never a placeholder, a +// or a longer word; a slug after the number still names the record. +func TestHandleInTextFindsGenealogy(t *testing.T) { + for s, want := range map[string]string{ + "as adr-43 ruled": "adr-43", + "(itd-79)": "itd-79", + "ADR-6's concern": "ADR-6", + "see rdi-2609020000000009.": "rdi-2609020000000009", + "while cond-2609020626047525 held": "cond-2609020626047525", + "rfm-1": "rfm-1", + "the file itd-4-a-slug.md": "itd-4", + } { + got, ok := HandleInText(s) + if !ok || got != want { + t.Errorf("HandleInText(%q) = %q, %v; want %q", s, got, ok, want) + } + } + for _, s := range []string{ + "a placeholder itd-N is not a citation", + "no handle here", + "xadr-4 is a longer word", + "iss-", + } { + if got, ok := HandleInText(s); ok { + t.Errorf("HandleInText(%q) found %q", s, got) + } + } +} diff --git a/internal/core/recordid/resolve.go b/internal/core/recordid/resolve.go index 56c928449..8410deb28 100644 --- a/internal/core/recordid/resolve.go +++ b/internal/core/recordid/resolve.go @@ -9,9 +9,11 @@ package recordid // next to a consumer (an ingest seam, a lint rule) would drift from the minting // side the first time a family moved. // -// Only the four ID-BEARING families are resolvable. The brief and the principles -// carry no per-entry id, so a citation of one cannot be validated by id at all; -// a caller that needs to reference them does so in prose, never as a citation. +// Only the four ID-BEARING families are resolvable. The brief carries no +// per-entry id, and the principles carry a slug-keyed one (`prn-<filename +// stem>`, adr-2609021016270132) that no citation grammar admits, so a citation +// of either cannot be validated by id here; a caller that needs to reference +// them does so in prose, never as a citation. // // The scan is deliberately SHALLOW (a family root plus one bucket level) and // symlink-refusing: the record families are flat-with-buckets by construction, an @@ -327,3 +329,28 @@ func readDirIfPresent(dir string) ([]os.DirEntry, error) { } return os.ReadDir(dir) } + +// handleInTextRe finds a record handle written anywhere in running text: any +// family the record mints a numbered id in, or a scope-condition identity, as a +// whole token in any case. It is wider than CitedIDRe on purpose. CitedIDRe +// bounds what an INGEST accepts as a citation; this answers whether a piece of +// prose carries genealogy at all — a reading item, a disposition or a condition +// named in a sentence is as much a pointer back into the record as an ADR is. +// A slug after the number does not stop it being one: `itd-4-a-slug` names +// itd-4, which is prose_citation_resolves' reading of the same shape. +var handleInTextRe = regexp.MustCompile( + `(?i)(?:^|[^A-Za-z0-9-])((?:adr|itd|spc|iss|rdi|rdg|dsp|adm|srp|rfm|cond)-[0-9]+)(?:$|[^A-Za-z0-9])`) + +// HandleInText returns the first record handle the text carries, and whether it +// carries one. It is the one answer the principles lint and the reading +// assembler give to "does this statement cite?" (spc-2609020626042471): the lint +// refuses a typed principle whose statement cites, and the assembler refuses an +// assembly whose projected principle still does, so the two cannot disagree +// about what a citation looks like. +func HandleInText(s string) (string, bool) { + m := handleInTextRe.FindStringSubmatch(s) + if m == nil { + return "", false + } + return m[1], true +} diff --git a/internal/core/site/build.go b/internal/core/site/build.go index 531786458..2b5d3a3a0 100644 --- a/internal/core/site/build.go +++ b/internal/core/site/build.go @@ -376,9 +376,11 @@ func Build(req Request) (Result, error) { if err != nil { return Result{}, err } - // The principle store carries no frontmatter, so the lint scan cannot see - // it. It joins the graph here, and a repository that keeps none simply has - // none — the pages that would list them are omitted. + graph = withoutPrincipleNodes(graph) + // The principle store joins the graph here, from its files: the file name + // is the handle every page carries and the H1 is the title, and a + // repository that keeps none simply has none — the pages that would list + // them are omitted. principles, err := LoadPrinciples(repoRoot, PrinciplesDir(lintCfg)) if err != nil { return Result{}, err diff --git a/internal/core/site/fixture_test.go b/internal/core/site/fixture_test.go index eeeb99f91..6d4f6cc07 100644 --- a/internal/core/site/fixture_test.go +++ b/internal/core/site/fixture_test.go @@ -247,7 +247,8 @@ func (f *fixture) writeSources() { "adr": ".abcd/development/decisions/adrs", "itd": ".abcd/development/intents", "spc": ".abcd/development/specs", - "iss": ".abcd/work/issues" + "iss": ".abcd/work/issues", + "prn": ".abcd/development/principles" }} } } diff --git a/internal/core/site/foundations.go b/internal/core/site/foundations.go index ccca52713..3b8d0d269 100644 --- a/internal/core/site/foundations.go +++ b/internal/core/site/foundations.go @@ -1,13 +1,17 @@ package site -// Principles — the one record family that carries no frontmatter. +// Principles — the record family keyed by file name. // // A principle is a markdown file whose name is its handle and whose H1 is its -// title; there is no id field to read and no lifecycle directory to sit in, so -// the lint engine's frontmatter scan cannot see one. They are read here instead -// — a directory listing and a first heading, through the same section walk every -// other page uses — and joined to the record graph as nodes of their own, so the -// dashboard can count them, the chart can draw them and each gets a page. +// title; it has no lifecycle directory to sit in, and most carry no +// frontmatter at all (a typed one carries four claim keys the site does not +// publish). The lint scan reads the family as a slug-keyed store under the +// handle prn-<stem>; the site reads it here instead — a directory listing and a +// first heading, through the same section walk every other page uses — and +// joins it to the record graph under the stem, the handle every published page +// carries, so the dashboard can count them, the chart can draw them and each +// gets a page (withoutPrincipleNodes keeps the two reads from publishing one +// record twice). // // The directory is DERIVED, never configured: it is `principles/` under the // record root the lint configuration already names. A repository that keeps none @@ -101,6 +105,44 @@ func LoadPrinciples(repoRoot, dir string) ([]lint.RecordNode, error) { return out, nil } +// withoutPrincipleNodes drops the principle nodes the record graph carries, and +// any edge touching one. +// +// The lint scan reads the principles store as a declared, slug-keyed record +// family (adr-2609021016270132), so the graph carries each principle under its +// record handle, prn-<stem>. The site publishes the same file under the stem +// alone, the handle its pages and links have always carried, and one record +// published twice under two handles is the duplicate this drop prevents. The +// site's own read is kept rather than the graph's because it is what every +// published page URL is keyed on; the graph's copy adds no field the site +// reads, since a principle carries none of the typed references the graph +// draws edges from. +func withoutPrincipleNodes(g lint.RecordGraph) lint.RecordGraph { + drop := map[string]bool{} + nodes := g.Nodes[:0:0] + for _, n := range g.Nodes { + if n.Type == principleType { + drop[n.ID] = true + continue + } + nodes = append(nodes, n) + } + if len(drop) == 0 { + return g + } + keep := func(es []lint.RecordEdge) []lint.RecordEdge { + out := es[:0:0] + for _, e := range es { + if !drop[e.From] && !drop[e.To] { + out = append(out, e) + } + } + return out + } + g.Nodes, g.Edges, g.Dangling = nodes, keep(g.Edges), keep(g.Dangling) + return g +} + // firstHeading is a document's H1, or the fallback where it carries none. func firstHeading(rel, text, fallback string) string { body, consumed := StripFrontmatter(text) diff --git a/internal/core/site/recordjson.go b/internal/core/site/recordjson.go index 741380d20..c72833ad1 100644 --- a/internal/core/site/recordjson.go +++ b/internal/core/site/recordjson.go @@ -179,10 +179,11 @@ func BuildRecordExport(repoRoot, baselineRel string, graph lint.RecordGraph, ext nodes := graph.Nodes derived := map[string]bool{} if len(extra) > 0 { - // The frontmatter-free stores (principles) join the graph here rather - // than in the lint scan: they carry no typed references, so they add - // nodes and nothing else, and the scan stays the one parser of the - // record's typed shape. They are MARKED, so a page can tell a field a + // The file-keyed stores (principles) join the graph here under the + // handle the site publishes them by: they carry no typed references, so + // they add nodes and nothing else, and the scan stays the one parser of + // the record's typed shape (the scan's own principle nodes are dropped + // before this, by withoutPrincipleNodes). They are MARKED, so a page can tell a field a // record declared from one this build worked out from its file. existing := make(map[string]bool, len(nodes)) for _, n := range nodes { From 611fd9d3d72e5024f6b1e06f94b57bf722f1388c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:54:15 +0100 Subject: [PATCH 30/78] feat(reading): a principle travels to a reading as its statement alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The include table gains the knowledge record: `.abcd/development/principles` through the `prn` store, admitted at widening, entailment and detection, projected to one field, `The rule`, as kind `principle`. projectField gains a labelled-paragraph resolution between heading and frontmatter key: the first live paragraph opening `**The rule.**` (the same mdrecord.LabelledParagraph the principles lint reads), label removed, the H1 title placed above it, every inline link unwrapped to its label. The exclusion floor gains the four claim keys under field projection, the citation entry ("the statement is knowledge and the citations are genealogy"), and the family as a directory at comparative, where the row withdraws. verifyPrincipleItem refuses an assembly whose principle item still carries a record handle or a link, so the citation assertion is checked rather than trusted. Kinds() gains `principle`, so SchemaVersion goes 10 to 11 (the closed kind vocabulary DecodeManifest enforces) and AssemblerVersionCore 1.10.0 to 1.11.0 (MINOR: a source, a vocabulary member, refusals). The charter is regenerated. The widening, entailment and detection definitions list the family among their sources and move PATCH (0.2.3, 0.1.3, 0.1.4); agents/CHANGELOG.md records them under the Iteration 2 heading. No committed preset names the kind, so no reading receives principles until a preset entry does, under the presets' eval. The read-block eval plants PRINCIPLE-CITATION in the evidence key, the **Why.** paragraph and a link target in the statement, plus an evidence key on the whole-travelling spec; the holed variant relocates it into the statement as a bare token. Coverage gains five rows; the pinned table counts move (sentinelClasses 23, carriers 20, materialClasses 12, holes 4, excludedKeys 6, excludedFamilies 24, admittedRecordPaths 14, coverage 85). Decisions not in the record: - verifyPrincipleItem runs over what the committed entry selected, not the unfiltered walk: an untyped principle whose statement names a record (examples-use-reserved-identifiers names itd-79) would otherwise refuse every real assembly at three positions though no run is handed a principle. - the family's directory being absent is a state, as the readings store's is; the record_stores key is still required. - the comparative directory exclusion is added because the oracle's family-absence check requires the manifest to assert it. - the key rows' coverage needed a whole-travelling home (a projected principle never carries its frontmatter), so the spec fixture carries the fourth plant. The window eval stays red, as it is at the base: widening ~1064761 (base ~1053896) against 1000000, detection ~1073797 (base ~1062932) against 1010000 — the growth is this lane's own source and tests. Part of itd-2609020625405170 / spc-2609020626042471. Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/23-reading.md | 11 + .abcd/development/readings/README.md | 7 + agents/CHANGELOG.md | 34 ++- agents/cold-reading-detection.md | 3 +- agents/cold-reading-entailment.md | 3 +- agents/cold-reading-widening.md | 3 +- evals/coldreading_coverage_test.go | 30 ++ evals/coldreading_fixture_test.go | 52 ++++ evals/coldreading_oracle_test.go | 38 ++- .../.abcd/config/reading-presets.json | 6 +- .../principles/a-typed-principle.md | 16 ++ .../specs/open/spc-1-a-design-record.md | 1 + .../baseline/.abcd/record-lint.json | 3 +- .../principles/a-typed-principle.md | 14 + internal/core/reading/assemble.go | 50 +++- internal/core/reading/definitions_test.go | 11 +- internal/core/reading/fixture_test.go | 3 +- internal/core/reading/include.go | 74 ++++- internal/core/reading/include_test.go | 2 +- internal/core/reading/manifest.go | 8 +- internal/core/reading/principle_test.go | 260 ++++++++++++++++++ internal/core/reading/project.go | 44 ++- internal/surface/cli/reading_surface_test.go | 2 +- 23 files changed, 645 insertions(+), 30 deletions(-) create mode 100644 evals/testdata/cold-reading/baseline/.abcd/development/principles/a-typed-principle.md create mode 100644 evals/testdata/cold-reading/holed/.abcd/development/principles/a-typed-principle.md create mode 100644 internal/core/reading/principle_test.go diff --git a/.abcd/development/brief/04-surfaces/23-reading.md b/.abcd/development/brief/04-surfaces/23-reading.md index 335443b40..7496bc6b6 100644 --- a/.abcd/development/brief/04-surfaces/23-reading.md +++ b/.abcd/development/brief/04-surfaces/23-reading.md @@ -103,6 +103,17 @@ The table is Go data, rendered into the readings family's charter under a test holding the two to each other. The exclusion floor rides in every manifest, each entry with the signal by which a reader detects it. +**The knowledge record travels as statements** +([adr-2609021016270132](../../decisions/adrs/2609021016270132-the-principles-family-is-a-declared-record-store-whose-entri.md)). +The principles family is admitted at the widening, entailment and detection +positions and projected to one field: each principle's H1 title above its +`**The rule.**` paragraph, with every link unwrapped to its label. Its four claim +keys and its citations stay behind, and the floor asserts both; an assembly whose +principle item still carries a record handle is refused rather than stamped. The +table admits the family, and a committed entry naming the `principle` kind is what +hands it to a run; the committed entries name no such kind, so no reading +receives the knowledge record until one does. + ## Two artefacts, and where they land Assembly writes the assembled input and the manifest as two separate files: the diff --git a/.abcd/development/readings/README.md b/.abcd/development/readings/README.md index cbec86729..dc611a141 100644 --- a/.abcd/development/readings/README.md +++ b/.abcd/development/readings/README.md @@ -131,6 +131,7 @@ disclosed as residue. | widening, entailment, detection | `.abcd/development/specs` | `.md` | none | the whole file | `spc` | every | `spec` | `parsed` | The design record a capability was built against | | entailment | `.abcd/development/intents/drafts` | `.md` | none | `Press Release`, `Acceptance Criteria`, `Scope Conditions`, `Mechanism`, `spec_id` | `itd` | `drafts` | `intent-projection` | `parsed` | Assembler rule 2: articulation precedes selection, so entailment sees the candidate set and the reading asked to widen it does not | | entailment | `.abcd/development/intents/planned` | `.md` | none | `Press Release`, `Acceptance Criteria`, `Scope Conditions`, `Mechanism`, `spec_id` | `itd` | `planned` | `intent-projection` | `parsed` | Assembler rule 2: articulation precedes selection, so entailment sees the candidate set and the reading asked to widen it does not | +| widening, entailment, detection | `.abcd/development/principles` | `.md` | none | `The rule` | `prn` | every | `principle` | `parsed` | The knowledge record is a read object: a principle travels as its statement, and its keys and citations are genealogy (adr-2609021016270132) | | widening, entailment, detection | `.` | none | `_test.go` | the whole file | every | every | `test` | `unscanned` | Admitted where a committed preset entry names this kind, and never examined: an item admitted here travels whole and marked `unscanned` in the manifest, because the exclusion floor's key and heading signals are record shapes only a markdown file carries | | widening, entailment, detection | `.` | `.go` | none | the whole file | every | every | `source` | `unscanned` | Admitted where a committed preset entry names this kind, and never examined: an item admitted here travels whole and marked `unscanned` in the manifest, because the exclusion floor's key and heading signals are record shapes only a markdown file carries | | widening, entailment, detection | `.` | `.md` | none | the whole file | every | every | `doc` | `parsed` | Assembler rule 1: the shipped tree is the delivered documentation and the root prose, with the record denied structurally | @@ -143,6 +144,10 @@ disclosed as residue. | --- | --- | --- | --- | | `origin` | frontmatter key | field projection | every position | | `production_mode` | frontmatter key | field projection | every position | +| `claim_type` | frontmatter key | field projection | every position | +| `reference` | frontmatter key | field projection | every position | +| `comparison` | frontmatter key | field projection | every position | +| `evidence` | frontmatter key | field projection | every position | | `Audit Notes` | heading | field projection | every position | | `Open Questions` | heading | field projection | every position | | `Why This Matters` | heading | field projection | every position | @@ -155,6 +160,7 @@ disclosed as residue. | `.abcd/development/research/notes` | directory | absent from the positive walk | every position | | `.abcd/work/issues` | directory | no include names a directory containing a record family | widening, entailment, detection | | `.abcd/work/DECISIONS.md` | file | absent from the positive walk | every position | +| `record handles and links in a principle` | citation | the statement is knowledge and the citations are genealogy | every position | | `.abcd/.work.local` | directory | no reading consumes the local ledger side, unconditionally and under no flag (brief invariant 14) | every position | | `the lapse log` | record type in a denied path | absent from the positive walk | every position | | `the reframe record` | record type in a denied path | absent from the positive walk | every position | @@ -167,6 +173,7 @@ disclosed as residue. | `.abcd/development/intents/drafts` | directory | a reading's object excludes what it exists to change | widening, comparative, detection | | `.abcd/development/intents/planned` | directory | a reading's object excludes what it exists to change | widening, comparative, detection | | `.abcd/development/intents/shipped` | directory | the widening object as the design documents state it | widening | +| `.abcd/development/principles` | directory | the comparative reading receives the candidates and the criteria alone | comparative | | `.abcd/work/issues/open` | directory | the comparative reading receives candidates and never their fate: the ledger's other families are excluded family by family, derived from the ledger's own directory list | comparative | | `.abcd/work/issues/resolved` | directory | the comparative reading receives candidates and never their fate: the ledger's other families are excluded family by family, derived from the ledger's own directory list | comparative | | `.abcd/work/issues/wontfix` | directory | the comparative reading receives candidates and never their fate: the ledger's other families are excluded family by family, derived from the ledger's own directory list | comparative | diff --git a/agents/CHANGELOG.md b/agents/CHANGELOG.md index 055af42ea..669e9a719 100644 --- a/agents/CHANGELOG.md +++ b/agents/CHANGELOG.md @@ -12,7 +12,7 @@ over the brief's earlier `1.0.0`-at-close expectation). The four M6 synthesis agents below entered at `0.1.0`, wired to their `abcd disembark` verbs and unmeasured; `lifeboat-oracle` has since become `lifeboat-reviewer` at `0.1.1`. -## 2026-09-25 (itd-2609020625402599 — the scribe's context is assembled and its output is ingested) +## 2026-09-25 (itd-2609020625402599, itd-2609020625405170 — the scribe's context is assembled and its output is ingested; the knowledge record becomes a read object) The scribe is a verb rather than a protocol. `abcd scribe assemble` builds the scribe's context from an allow list derived from the issue ledger's own @@ -30,6 +30,38 @@ four parts in place of "there is no ingest verb". The access rule is unchanged ledger content only — and so are the record shapes, the fidelity-flag rule and the contribution stamp. Unmeasured, as before. +The knowledge record becomes a read object. A principle may declare four typed +keys — `claim_type`, `reference`, `comparison` and `evidence` — and the reading +assembler admits the principles family at the widening, entailment and +detection positions as each principle's title and `**The rule.**` paragraph, +withholding the keys and every citation (adr-2609021016270132). + +### principle-distiller 0.2.0 + +MINOR: the principles payload moves to schema version 2 and every entry carries +the three new claim keys beside its evidence — `claim_type` (one of `criterion`, +`causal`, `context`, or `null`), `reference` and `comparison` (a string or +`null`). An entry missing any of them is dropped with its reason; a `mechanism` +claim type is written back as `causal`. The citation discipline is unchanged. +Unmeasured, as before. + +### cold-reading-widening 0.2.3 + +PATCH: the object's source list gains `.abcd/development/principles`, which the +include table admits at the widening, entailment and detection positions, stated +as each principle's title and statement alone. The question, the blindness core, +the regime and the item shape are untouched. Unmeasured, as before. + +### cold-reading-entailment 0.1.3 + +PATCH: the same source-list line as widening's, for the same reason. + +### cold-reading-detection 0.1.4 + +PATCH: the same source-list line as widening's, for the same reason. The +comparative definition, whose position does not admit the family, does not +move. + ## 2026-09-25 (itd-2609020625405251 — a detection item cites the condition it names) Iteration 2's condition disposition joins a researcher's mark on a scope diff --git a/agents/cold-reading-detection.md b/agents/cold-reading-detection.md index 7a24ba0b8..fc701a76e 100644 --- a/agents/cold-reading-detection.md +++ b/agents/cold-reading-detection.md @@ -4,7 +4,7 @@ description: >- Cold reading at the detection position. Where is the shipped tree in tension with the claim record? Returns tensions, each with the constraint in play and why it is a tension, under the registrative supply regime. -prompt_version: 0.1.3 +prompt_version: 0.1.4 reads_untrusted_input: true capability_scope: task_classes: [cold_reading] @@ -36,6 +36,7 @@ a claim nobody has committed to is not a tension. - `.abcd/development/intents/disciplines` — the standing commitments the record already holds. - `.abcd/development/intents/shipped` — each shipped intent as its claim record. - `.abcd/development/specs` — the design record a capability was built against. +- `.abcd/development/principles` — the knowledge record, each principle as its title and statement alone. - `.` — the shipped tree: source, tests, delivered documentation, root prose and build configuration. diff --git a/agents/cold-reading-entailment.md b/agents/cold-reading-entailment.md index 3bf01d88e..6af05f189 100644 --- a/agents/cold-reading-entailment.md +++ b/agents/cold-reading-entailment.md @@ -5,7 +5,7 @@ description: >- being the kind of thing it is, that its articulation does not state? Returns surfaced claims, each with its claim type and what implies it, under the explicative supply regime. -prompt_version: 0.1.2 +prompt_version: 0.1.3 reads_untrusted_input: true capability_scope: task_classes: [cold_reading] @@ -39,6 +39,7 @@ a claim, and what it commits to is already true of it. - `.abcd/development/specs` — the design record a capability was built against. - `.abcd/development/intents/drafts` — the candidate set as articulated. - `.abcd/development/intents/planned` — the candidate set as planned. +- `.abcd/development/principles` — the knowledge record, each principle as its title and statement alone. - `.` — the shipped tree: source, tests, delivered documentation, root prose and build configuration. diff --git a/agents/cold-reading-widening.md b/agents/cold-reading-widening.md index 5f699a2e4..d258150ce 100644 --- a/agents/cold-reading-widening.md +++ b/agents/cold-reading-widening.md @@ -5,7 +5,7 @@ description: >- construes it, what configurations does the construal admit that are not present in what has been committed to? Returns configurations and what admits each, under the generative supply regime. -prompt_version: 0.2.2 +prompt_version: 0.2.3 reads_untrusted_input: true capability_scope: task_classes: [cold_reading] @@ -38,6 +38,7 @@ do not reason about what might have been. - `.abcd/development/brief` — the meta chapter, the one file `00-meta.md` at the brief's root. - `.abcd/development/intents/disciplines` — the standing commitments the record already holds. - `.abcd/development/specs` — the design record a capability was built against. +- `.abcd/development/principles` — the knowledge record, each principle as its title and statement alone. - `.` — the shipped tree: source, tests, delivered documentation, root prose and build configuration. diff --git a/evals/coldreading_coverage_test.go b/evals/coldreading_coverage_test.go index c754996fa..63eb2693c 100644 --- a/evals/coldreading_coverage_test.go +++ b/evals/coldreading_coverage_test.go @@ -697,6 +697,36 @@ var coverage = []coverageRow{ Falsifier: "drop the admissions directory from issueschema.LedgerDirs, so the derived row disappears", Caught: caughtFamily, }, + // ---- the knowledge record (spc-2609020626042471) ---- + { + Rule: "principles are admitted as their statement at the three assembling positions", + Falsifier: "delete the principle row from Table", + Caught: caughtCarrier, + Classes: []string{"PRINCIPLE-CITATION"}, + }, + { + Rule: "the four principle claim keys never travel", + Falsifier: "delete the four claim-key rows from Exclusions", + Caught: caughtLeak, + Classes: []string{"PRINCIPLE-CITATION"}, + }, + { + Rule: "a principle's citations never travel: it is projected to its statement", + Falsifier: "project the whole file on the principle row (empty its Fields)", + Caught: caughtLeak, + Classes: []string{"PRINCIPLE-CITATION"}, + }, + { + Rule: "a link in a principle's statement travels as its label", + Falsifier: "stop unwrapping links in the labelled-paragraph resolution", + Caught: caughtLeak, + Classes: []string{"PRINCIPLE-CITATION"}, + }, + { + Rule: "the principle row is not admitted at the comparative position, and its manifest says so", + Falsifier: "add the comparative position to the principle row", + Caught: caughtFamily, + }, { Rule: "surprises never reach the comparative reading, and its manifest says so", Falsifier: "drop the surprises directory from issueschema.LedgerDirs, so the derived row disappears", diff --git a/evals/coldreading_fixture_test.go b/evals/coldreading_fixture_test.go index 1a2f225ec..1428075ea 100644 --- a/evals/coldreading_fixture_test.go +++ b/evals/coldreading_fixture_test.go @@ -321,6 +321,28 @@ var sentinelClasses = []sentinelClass{ "exclusion is asserted in every manifest, by the ledger rows and by the floor's " + "own reframe row", }, + { + // The knowledge record's genealogy (spc-2609020626042471). A typed + // principle carries it in three homes: a claim key (`evidence`), the + // reasoning below the statement (`**Why.**`, beside a record handle), + // and a link TARGET inside the statement itself. The statement travels + // and every one of the three stays behind: the reasoning by the + // projection, and the link target by the projection's unwrapping to the + // label. The key is stripped by the floor, but a projected principle + // never carries its frontmatter at all, so the key rows need a home on + // a record that travels WHOLE to be falsifiable — the fourth plant, an + // `evidence` key on the spec, on the same ground an excluded heading + // keeps a home on one (testdata/cold-reading/README.md). Warm at every + // position. + Name: "PRINCIPLE-CITATION", + Homes: []string{ + "repo:.abcd/development/principles/a-typed-principle.md", + "repo:.abcd/development/specs/open/spc-1-a-design-record.md", + }, + Count: 4, + Why: "adr-2609021016270132: a principle is read cold by its statement and never by " + + "its citations, which are genealogy", + }, { Name: "DEFINITION", Homes: []string{"repo:agents/cold-reading-widening.md"}, @@ -475,6 +497,20 @@ var holes = []hole{ Why: "the derived widening run's items are admitted at the comparative position and " + "projected to two body fields, so a fate relocated into one of those fields travels", }, + { + // The knowledge record's control (spc-2609020626042471, ac-7). The + // link-target plant in the baseline cannot leak while the unwrapper + // stands, so it falsifies the unwrapping alone; this relocation is what + // exercises the read block over the statement — the citation class + // moved into the `**The rule.**` paragraph as a bare token, which the + // projection carries into the bundle and the control must report. + Class: "PRINCIPLE-CITATION", + From: ".abcd/development/principles/a-typed-principle.md", + To: ".abcd/development/principles/a-typed-principle.md", + Positions: []string{posWidening, posEntailment, posDetection}, + Why: "a principle's statement is projected to every position whose entry admits the " + + "knowledge record, so a token relocated into it travels", + }, } // refusedPrefix is the shape a refusal plant's token takes. It is deliberately @@ -850,6 +886,22 @@ var carriers = []carrier{ Why: "the criteria discipline, which the assembler narrows the disciplines row to at " + "this position; a comparative reading with no criteria characterises against nothing", }, + { + // The knowledge record's carrier (spc-2609020626042471). The fixture + // preset admits the principle kind at the three assembling positions, so + // the principle row is falsifiable by presence: deleting it leaks nothing + // and only this floor sees it go. Fields pins the projection to the one + // statement field, so a row projecting the whole file is told apart from + // one projecting the statement. + Path: ".abcd/development/principles/a-typed-principle.md", + Positions: []string{posWidening, posEntailment, posDetection}, + Markers: []string{"ABCD-EVAL-PRINCIPLE-STATEMENT travels as the knowledge record"}, + Fields: []string{"The rule"}, + Scan: "parsed", + Classes: []string{"PRINCIPLE-CITATION"}, + Why: "a typed principle, admitted as its statement; the carrier is what makes the " + + "principles row falsifiable", + }, { // The live leak's own shape: a Go test file carrying record-shaped // markdown with a literal `## Audit Notes` section. itd-194 does not diff --git a/evals/coldreading_oracle_test.go b/evals/coldreading_oracle_test.go index d67a89c89..c83154141 100644 --- a/evals/coldreading_oracle_test.go +++ b/evals/coldreading_oracle_test.go @@ -53,6 +53,10 @@ type excludedKey struct { var excludedKeys = []excludedKey{ {Key: "origin", Source: "itd-183 exclusion list: `origin`, detected by frontmatter key"}, {Key: "production_mode", Source: "itd-183 exclusion list: production mode, detected by frontmatter key"}, + {Key: "claim_type", Source: "spc-2609020626042471: a principle's claim keys are genealogy, and it travels as its statement"}, + {Key: "reference", Source: "spc-2609020626042471: a principle's claim keys are genealogy, and it travels as its statement"}, + {Key: "comparison", Source: "spc-2609020626042471: a principle's claim keys are genealogy, and it travels as its statement"}, + {Key: "evidence", Source: "spc-2609020626042471: a principle's claim keys are genealogy, and it travels as its statement"}, } // excludedHeading is one heading the record refuses. @@ -174,6 +178,12 @@ var excludedFamilies = []excludedFamily{ "5.2 both state that object without the shipped intents, so the widening position " + "withdraws from the row and the floor asserts the withdrawal (iss-2609012259587904)", }, + { + Path: ".abcd/development/principles", + Positions: []string{posComparative}, + Source: "spc-2609020626042471: at the comparative position the include table admits the " + + "candidates and the criteria alone, so the knowledge record is not among its sources", + }, } // bindsAt reports whether the exclusion binds at position p. @@ -224,6 +234,12 @@ var admittedRecordPaths = []admittedRecordPath{ }, {Path: ".abcd/development/intents/disciplines", Source: "itd-183 include list"}, {Path: ".abcd/development/specs", Source: "itd-183 include list"}, + { + Path: ".abcd/development/principles", + Positions: []string{posWidening, posEntailment, posDetection}, + Source: "spc-2609020626042471: the knowledge record is a read object, admitted as each " + + "principle's statement at the three positions that read repository material", + }, { Path: ".abcd/development/intents/drafts", Positions: []string{posEntailment}, @@ -358,6 +374,12 @@ var materialClasses = []materialClass{ Match: []string{".md"}, Source: "itd-183 include list: the design record a capability was built against", }, + { + Kind: "principle", + Under: []string{".abcd/development/principles"}, + Match: []string{".md"}, + Source: "spc-2609020626042471: a principle of the knowledge record, projected to its statement", + }, { Kind: "test", Suffix: []string{"_test.go"}, @@ -638,16 +660,16 @@ func requireOracleTables(t *testing.T) { got int want int }{ - {"sentinelClasses", len(sentinelClasses), 22}, - {"carriers", len(carriers), 19}, - {"materialClasses", len(materialClasses), 11}, - {"holes", len(holes), 3}, + {"sentinelClasses", len(sentinelClasses), 23}, + {"carriers", len(carriers), 20}, + {"materialClasses", len(materialClasses), 12}, + {"holes", len(holes), 4}, {"refusals", len(refusals), 8}, - {"excludedKeys", len(excludedKeys), 2}, + {"excludedKeys", len(excludedKeys), 6}, {"excludedHeadings", len(excludedHeadings), 4}, - {"excludedFamilies", len(excludedFamilies), 23}, - {"admittedRecordPaths", len(admittedRecordPaths), 13}, - {"coverage", len(coverage), 80}, + {"excludedFamilies", len(excludedFamilies), 24}, + {"admittedRecordPaths", len(admittedRecordPaths), 14}, + {"coverage", len(coverage), 85}, } { if tbl.got != tbl.want { t.Fatalf("the %s table holds %d row(s), and this eval is written against %d; "+ diff --git a/evals/testdata/cold-reading/baseline/.abcd/config/reading-presets.json b/evals/testdata/cold-reading/baseline/.abcd/config/reading-presets.json index 1c0f6e980..6d07f2c68 100644 --- a/evals/testdata/cold-reading/baseline/.abcd/config/reading-presets.json +++ b/evals/testdata/cold-reading/baseline/.abcd/config/reading-presets.json @@ -3,10 +3,10 @@ "presets": { "everything": { "positions": { - "widening": {"kinds": ["brief-section", "glossary-term", "intent-projection", "discipline", "spec", "source", "test", "doc", "config"], "records": [], "paths": ["Makefile", "README.md", "docs", "fence.go", "go.mod", "main.go", "main_test.go", "sitefixture_test.go"]}, - "entailment": {"kinds": ["brief-section", "glossary-term", "intent-projection", "discipline", "spec", "source", "test", "doc", "config"], "records": ["itd-2", "itd-3", "itd-5"], "paths": ["Makefile", "README.md", "docs", "fence.go", "go.mod", "main.go", "main_test.go", "sitefixture_test.go"]}, + "widening": {"kinds": ["brief-section", "glossary-term", "intent-projection", "discipline", "spec", "principle", "source", "test", "doc", "config"], "records": [], "paths": ["Makefile", "README.md", "docs", "fence.go", "go.mod", "main.go", "main_test.go", "sitefixture_test.go"]}, + "entailment": {"kinds": ["brief-section", "glossary-term", "intent-projection", "discipline", "spec", "principle", "source", "test", "doc", "config"], "records": ["itd-2", "itd-3", "itd-5"], "paths": ["Makefile", "README.md", "docs", "fence.go", "go.mod", "main.go", "main_test.go", "sitefixture_test.go"]}, "comparative": {"kinds": ["discipline"], "records": ["itd-191"], "paths": []}, - "detection": {"kinds": ["brief-section", "glossary-term", "intent-projection", "discipline", "spec", "source", "test", "doc", "config"], "records": [], "paths": ["Makefile", "README.md", "docs", "fence.go", "go.mod", "main.go", "main_test.go", "sitefixture_test.go"]} + "detection": {"kinds": ["brief-section", "glossary-term", "intent-projection", "discipline", "spec", "principle", "source", "test", "doc", "config"], "records": [], "paths": ["Makefile", "README.md", "docs", "fence.go", "go.mod", "main.go", "main_test.go", "sitefixture_test.go"]} } } } diff --git a/evals/testdata/cold-reading/baseline/.abcd/development/principles/a-typed-principle.md b/evals/testdata/cold-reading/baseline/.abcd/development/principles/a-typed-principle.md new file mode 100644 index 000000000..ca80525a1 --- /dev/null +++ b/evals/testdata/cold-reading/baseline/.abcd/development/principles/a-typed-principle.md @@ -0,0 +1,16 @@ +--- +id: prn-a-typed-principle +claim_type: causal +reference: "the fixture assembler" +comparison: "A statement read cold against the records it was distilled from." +evidence: [itd-181, ABCD-EVAL-SENTINEL-PRINCIPLE-CITATION] +--- + +# A typed principle + +**The rule.** ABCD-EVAL-PRINCIPLE-STATEMENT travels as the knowledge record, as +[the ruling](ABCD-EVAL-SENTINEL-PRINCIPLE-CITATION) states it. + +**Why.** Because adr-1 said so: ABCD-EVAL-SENTINEL-PRINCIPLE-CITATION. + +**Bounds.** Nothing after the statement travels. diff --git a/evals/testdata/cold-reading/baseline/.abcd/development/specs/open/spc-1-a-design-record.md b/evals/testdata/cold-reading/baseline/.abcd/development/specs/open/spc-1-a-design-record.md index 3b8ee7703..3ad3a6300 100644 --- a/evals/testdata/cold-reading/baseline/.abcd/development/specs/open/spc-1-a-design-record.md +++ b/evals/testdata/cold-reading/baseline/.abcd/development/specs/open/spc-1-a-design-record.md @@ -2,6 +2,7 @@ id: spc-1 intent: itd-1 origin: ABCD-EVAL-SENTINEL-WARM-KEY +evidence: [ABCD-EVAL-SENTINEL-PRINCIPLE-CITATION] --- # A design record diff --git a/evals/testdata/cold-reading/baseline/.abcd/record-lint.json b/evals/testdata/cold-reading/baseline/.abcd/record-lint.json index 9dbc8d2f3..574da9346 100644 --- a/evals/testdata/cold-reading/baseline/.abcd/record-lint.json +++ b/evals/testdata/cold-reading/baseline/.abcd/record-lint.json @@ -12,7 +12,8 @@ "rdi": ".abcd/work/issues/readings", "dsp": ".abcd/work/issues/dispositions", "adm": ".abcd/work/issues/admissions", - "rdg": ".abcd/development/readings" + "rdg": ".abcd/development/readings", + "prn": ".abcd/development/principles" } } } diff --git a/evals/testdata/cold-reading/holed/.abcd/development/principles/a-typed-principle.md b/evals/testdata/cold-reading/holed/.abcd/development/principles/a-typed-principle.md new file mode 100644 index 000000000..fb191a150 --- /dev/null +++ b/evals/testdata/cold-reading/holed/.abcd/development/principles/a-typed-principle.md @@ -0,0 +1,14 @@ +--- +id: prn-a-typed-principle +claim_type: causal +reference: "the fixture assembler" +comparison: "A statement read cold against the records it was distilled from." +evidence: [itd-181, ABCD-EVAL-SENTINEL-PRINCIPLE-CITATION] +--- + +# A typed principle + +**The rule.** ABCD-EVAL-PRINCIPLE-STATEMENT travels as the knowledge record, +carrying ABCD-EVAL-SENTINEL-PRINCIPLE-CITATION as a bare token the projection keeps. + +**Why.** Because adr-1 said so: ABCD-EVAL-SENTINEL-PRINCIPLE-CITATION. diff --git a/internal/core/reading/assemble.go b/internal/core/reading/assemble.go index 411fb62ae..89ba60722 100644 --- a/internal/core/reading/assemble.go +++ b/internal/core/reading/assemble.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/lint" + "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/core/sessionkind" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" @@ -315,6 +316,9 @@ var storeNodeType = map[string]string{ // narrow that row to the derived run by setting Row.Bucket rather than by // growing a second selector (adr-2609021016272867). issueschema.ReadingItemFamily: "reading", + // The knowledge record (adr-2609021016270132): a slug-keyed store whose + // node id is prn-<filename stem>. + "prn": "principle", } // rowClass is how a committed entry's object set narrows the row that admitted @@ -497,6 +501,21 @@ func Assemble(req AssembleRequest) (AssembleResult, error) { } cands = scoped + // The citation half of the exclusion floor, made fail-closed. It runs over + // what the entry SELECTED rather than over the unfiltered walk, and that is + // the one gate here that does: the property it checks is the manifest's own + // assertion about the principle items it carries, so a principle no entry + // selected is carried by no manifest and asserted about by none. Run over the + // walk, it would refuse every assembly at the three positions the moment any + // UNTYPED principle's statement named a record — which the record lint does + // not judge until its author types it — whether or not the run was handed + // the knowledge record at all. + for _, c := range cands { + if err := verifyPrincipleItem(c); err != nil { + return AssembleResult{}, err + } + } + // The comparative position's two remaining facts: the criteria it // characterises against, and whether it is exercised at all. var criteria []string @@ -1177,6 +1196,29 @@ func collect(repoRoot string, position Position, candidateRun string) ([]candida return out, nil } +// verifyPrincipleItem refuses a principle item that still carries a citation +// after projection. The manifest asserts that a principle travels without its +// record handles and links (the floor's citation entry); the projection keeps +// only the statement and unwraps a link to its label, and this is what makes +// the assertion checked rather than trusted — a statement that names a record +// in its own words would otherwise ride into the bundle under a manifest saying +// it had not. +func verifyPrincipleItem(c candidate) error { + if c.kind != KindPrinciple { + return nil + } + if id, ok := recordid.HandleInText(c.text); ok { + return fmt.Errorf("reading: the principle %s carries the record handle %s in its projected "+ + "statement, and the manifest asserts a principle travels without its citations; move the "+ + "handle into the principle's evidence or below its statement", c.path, id) + } + if m := mdLinkRe.FindString(c.text); m != "" { + return fmt.Errorf("reading: the principle %s carries the link %s in its projected statement, "+ + "and the manifest asserts a principle travels without its citations", c.path, m) + } + return nil +} + // narrowRow applies the comparative position's two narrowings to one row before // it is enumerated. Both run BEFORE the committed entry is applied, which is // what makes them un-widenable: an entry intersects what the row admits, so a @@ -1307,7 +1349,13 @@ func requireConfiguredStores(repoRoot string, cfg lint.Config) error { // run qualifies, listing what there is. A store pointed at something that // exists and is not a directory is still refused below, for every store // alike (adr-2609021016272867). - if os.IsNotExist(err) && row.Store == issueschema.ReadingItemFamily { + // + // The KNOWLEDGE RECORD is the same state for the same reason: a + // repository that has written no principle has no principles directory, + // and the row enumerating nothing there is what the record holds rather + // than a hole — the manifest carries no principle item, and a committed + // entry naming the kind is handed none. + if os.IsNotExist(err) && (row.Store == issueschema.ReadingItemFamily || row.Store == "prn") { continue } if err != nil || !info.IsDir() { diff --git a/internal/core/reading/definitions_test.go b/internal/core/reading/definitions_test.go index 8da316f07..d681f9d3f 100644 --- a/internal/core/reading/definitions_test.go +++ b/internal/core/reading/definitions_test.go @@ -693,11 +693,16 @@ const retiredFourthConditionSentence = "Items come back unordered and unweighted // states the derivation rule — the only one of the four that has one to state — // and the rule now reaches a widening run at an ancestor of the target. A shared // edit still moves all four together; this one was not shared. +// +// The three assembling positions other than comparative moved PATCH together +// with spc-2609020626042471: their object's source list gains the knowledge +// record, which the include table admits there and not at comparative, so the +// comparative definition did not move. var promptVersions = map[Position]string{ - PositionWidening: "0.2.2", - PositionEntailment: "0.1.2", + PositionWidening: "0.2.3", + PositionEntailment: "0.1.3", PositionComparative: "0.1.3", - PositionDetection: "0.1.3", + PositionDetection: "0.1.4", } // TestDetectionItemShapeCitesAConditionIdentity is spc-2609020626046252's diff --git a/internal/core/reading/fixture_test.go b/internal/core/reading/fixture_test.go index 93d9678f9..7d0699a89 100644 --- a/internal/core/reading/fixture_test.go +++ b/internal/core/reading/fixture_test.go @@ -65,7 +65,8 @@ func fixtureRepo(t *testing.T) string { "itd": ".abcd/development/intents", "spc": ".abcd/development/specs", "iss": ".abcd/work/issues", - "rdi": ".abcd/work/issues/readings" + "rdi": ".abcd/work/issues/readings", + "prn": ".abcd/development/principles" } } } diff --git a/internal/core/reading/include.go b/internal/core/reading/include.go index 341bcbc14..e9c8ca4a0 100644 --- a/internal/core/reading/include.go +++ b/internal/core/reading/include.go @@ -78,7 +78,14 @@ import ( // PROMISED — it now carries a token naming its kind and its run — which is // MINOR by this constant's own rule (adr-2609021016275803, // spc-2609020626045177). -const AssemblerVersionCore = "1.10.0" +// It goes 1.10.0 to 1.11.0 with the knowledge record as a read object: Table +// gains the principles row at the three assembling positions, Kinds() gains +// `principle`, the projection gains its labelled-paragraph resolution, and +// Exclusions gains the four claim keys and the citation entry, which a new +// refusal enforces. A source admitted, a vocabulary member and refusals a +// reader can now check are each MINOR by this constant's own rule +// (adr-2609021016270132, spc-2609020626042471). +const AssemblerVersionCore = "1.11.0" // AssemblerVersion is the core semver with the rendered include table's digest // as semver build metadata. The digest is computed, not declared, so a table @@ -184,13 +191,18 @@ const ( // selects repository material, and a candidate is selected by the derived // run (adr-2609021016272867; validatePresets refuses it by name). KindCandidate Kind = "candidate" + // KindPrinciple is one principle of the knowledge record, projected to its + // statement: the H1 title and the `**The rule.**` paragraph, links unwrapped + // to their labels. Its four claim keys and every citation stay behind as + // genealogy (adr-2609021016270132, spc-2609020626042471). + KindPrinciple Kind = "principle" ) // Kinds lists the closed material-class vocabulary. func Kinds() []Kind { return []Kind{KindBriefSection, KindGlossaryTerm, KindIntentProjection, KindDiscipline, KindSpec, KindSource, KindTest, KindDoc, KindConfig, - KindCandidate} + KindCandidate, KindPrinciple} } // Scan says whether the exclusion floor examined an item, and it is ONE word @@ -327,6 +339,16 @@ var allPositions = []Position{PositionWidening, PositionEntailment, PositionComp // criteria discipline, because there is nothing else there for it to reach. var coldPositions = []Position{PositionWidening, PositionEntailment, PositionDetection} +// PrincipleSource is the knowledge record: one principle per file, each a +// statement and the genealogy it was distilled from. +const PrincipleSource = ".abcd/development/principles" + +// PrincipleField is the one field a principle travels as: its statement, the +// labelled paragraph `**The rule.**` with the H1 title above it (projectField's +// labelled-paragraph resolution). Everything after it — the reasons, the +// bounds, the promotion rung — and every frontmatter key stays behind. +const PrincipleField = "The rule" + // CandidateSource is the ledger directory the candidate row reaches: the working // tier's readings store, one directory per run. It is the leaf bucket the // readings family keys on, which is what assembler rule 1 permits an include row @@ -488,6 +510,29 @@ var Table = []Row{ Rule: "Assembler rule 2: articulation precedes selection, so entailment sees " + "the candidate set and the reading asked to widen it does not", }, + { + // The knowledge record as a read object (adr-2609021016270132). A + // principle's statement is what a project carries forward and sits on + // the cold side; its claim keys and its citations back to the records it + // was distilled from are derivational and stay behind, which is a + // projection rule rather than a path rule — so the row names the family + // and projects one field, and the floor below asserts the rest. + // + // Not at comparative: there the include table is the whole account and + // admits the candidates and the criteria alone (companion 7.2, R3). + // Which of the three assembling positions RECEIVES the knowledge record + // is a committed preset entry's choice, measured by the presets' eval; + // the table only admits it. + Positions: coldPositions, + Source: PrincipleSource, + Match: []string{".md"}, + Store: "prn", + Fields: []string{PrincipleField}, + Kind: KindPrinciple, + Scan: ScanParsed, + Rule: "The knowledge record is a read object: a principle travels as its statement, " + + "and its keys and citations are genealogy (adr-2609021016270132)", + }, { // Ordered above the .go row deliberately: path.Ext("foo_test.go") is // ".go", so the source row would otherwise claim every test file, and @@ -587,6 +632,14 @@ var Table = []Row{ var Exclusions = []Exclusion{ {Rule: "field projection", Signal: "frontmatter key", Detail: "origin"}, {Rule: "field projection", Signal: "frontmatter key", Detail: "production_mode"}, + // A principle's four claim keys (adr-2609021016270132): what kind of claim + // it makes, what it is about, what comparison produced it, and the records + // and conditions it rests on. They are genealogy, and a principle travels as + // its statement alone. + {Rule: "field projection", Signal: "frontmatter key", Detail: "claim_type"}, + {Rule: "field projection", Signal: "frontmatter key", Detail: "reference"}, + {Rule: "field projection", Signal: "frontmatter key", Detail: "comparison"}, + {Rule: "field projection", Signal: "frontmatter key", Detail: "evidence"}, {Rule: "field projection", Signal: "heading", Detail: "Audit Notes"}, {Rule: "field projection", Signal: "heading", Detail: "Open Questions"}, {Rule: "field projection", Signal: "heading", Detail: "Why This Matters"}, @@ -616,6 +669,12 @@ var Exclusions = []Exclusion{ Positions: coldPositions, }, {Rule: "absent from the positive walk", Signal: "file", Detail: ".abcd/work/DECISIONS.md"}, + // A principle's citations. The projection keeps the statement and unwraps a + // link to its label, and verifyPrincipleItem refuses the assembly if a + // record handle survives into a principle item, so this is an assertion the + // assembler checks rather than a disclosure a reader trusts. + {Rule: "the statement is knowledge and the citations are genealogy", Signal: "citation", + Detail: "record handles and links in a principle"}, // The local ledger tier. It was excluded from the first day and asserted by // nothing: absent from the positive walk, denied by the `.abcd` segment, and // named in no row here — so every manifest was silent about the one tier @@ -671,6 +730,17 @@ var Exclusions = []Exclusion{ Detail: ".abcd/development/intents/shipped", Positions: []Position{PositionWidening}, }, + // The knowledge record's one withdrawal. At comparative the include table + // is the whole account and admits the candidates and the criteria alone, so + // the principles row withdraws there; the floor asserts it, so a reader + // checks the withdrawal rather than inferring it from a row's silence, and + // assertExclusions enforces it by path (spc-2609020626042471). + { + Rule: "the comparative reading receives the candidates and the criteria alone", + Signal: "directory", + Detail: PrincipleSource, + Positions: []Position{PositionComparative}, + }, } // comparativeExclusions is what replaces the container row at the comparative diff --git a/internal/core/reading/include_test.go b/internal/core/reading/include_test.go index 684548dc8..c2ceaeca5 100644 --- a/internal/core/reading/include_test.go +++ b/internal/core/reading/include_test.go @@ -305,7 +305,7 @@ func TestBriefEvidenceChapterIsNeverAdmitted(t *testing.T) { // insufficient no longer matters: updating this literal without moving the core // can no longer make a manifest lie, because the manifest's digest is not this // literal. -const includeTableDigest = "34a45d87635c8ffacc01d06d3df038d56f7ded9da8a93f0d681f2736c406bee0" +const includeTableDigest = "cacc591ff9d463fe20118ddc72b5a12363aaca145d3810c3c0221f416114a75a" // TestAssemblerVersionCoversTheIncludeTable puts the core semver in front of // whoever changed the table. It is ADVISORY by construction — the fix for a red diff --git a/internal/core/reading/manifest.go b/internal/core/reading/manifest.go index b67b35cf5..5b4cab15c 100644 --- a/internal/core/reading/manifest.go +++ b/internal/core/reading/manifest.go @@ -44,8 +44,12 @@ import ( // `context_stamp`, the per-run token naming the reading kind, the run and a // digest of the item set, which a transcript retains and the separation check // reads (adr-2609021016275803, spc-2609020626045177); the manifest is untouched -// and is restamped by the shared constant. -const SchemaVersion = 10 +// and is restamped by the shared constant. At version 11 the closed `Kind` +// vocabulary gains `principle`, which `DecodeManifest` refuses when it does not +// know it, so a manifest carrying a principle item is a shape the previous +// version cannot read; the bundle is restamped by the shared constant +// (adr-2609021016270132, spc-2609020626042471). +const SchemaVersion = 11 // The two artefact type tags. They are carried in the documents themselves so a // reader of a loose file can tell the two apart without its filename. diff --git a/internal/core/reading/principle_test.go b/internal/core/reading/principle_test.go new file mode 100644 index 000000000..28ec9ffaa --- /dev/null +++ b/internal/core/reading/principle_test.go @@ -0,0 +1,260 @@ +package reading + +import ( + "fmt" + "strings" + "testing" +) + +// The knowledge record as a read object (spc-2609020626042471): a principle +// travels as its H1 title and its `**The rule.**` paragraph, and its four claim +// keys and every citation stay behind. + +const ( + principleRel = ".abcd/development/principles/fix-the-detector.md" + // The three homes a principle's genealogy has: a claim key, the reasoning + // below the statement, and a link target inside the statement itself. + sentinelPrincipleKey = "SENTINEL-PRINCIPLE-KEY" + sentinelPrincipleWhy = "SENTINEL-PRINCIPLE-WHY" + sentinelPrincipleLink = "SENTINEL-PRINCIPLE-LINK" + // The statement's own words, which a reading must receive. + principleStatement = "After a review, the unit of fix is the detector" +) + +// principleDoc is a typed principle carrying genealogy in every home it has. +func principleDoc(rule string) string { + return "---\nid: prn-fix-the-detector\nclaim_type: causal\nreference: \"abcd lint\"\n" + + "comparison: \"" + sentinelPrincipleKey + " hand fixes against a detector.\"\n" + + "evidence: [itd-181, cond-2608311949582375]\n---\n\n" + + "# Fix the detector\n\n" + + "**The rule.** " + rule + "\n\n" + + "**Why.** Because adr-1 said so, " + sentinelPrincipleWhy + ".\n\n" + + "**Bounds.** Nothing else travels.\n" +} + +const defaultRule = principleStatement + ", as [the ruling](" + sentinelPrincipleLink + ") says,\n" + + "not the finding." + +// principleFixture is the base fixture with one typed principle committed. +func principleFixture(t *testing.T, rule string) string { + t.Helper() + root := fixtureRepo(t) + writeFile(t, root, principleRel, principleDoc(rule)) + gitCommitAll(t, root) + return root +} + +// TestLabelledParagraphResolves: a field naming a label resolves as the first +// paragraph opening with it, label removed, and nothing after it. +func TestLabelledParagraphResolves(t *testing.T) { + text, ok, err := projectField(principleRel, principleDoc(defaultRule), "The rule") + if err != nil || !ok { + t.Fatalf("projectField(The rule) = %q, %v, %v", text, ok, err) + } + if !strings.Contains(text, principleStatement) || !strings.Contains(text, "not the finding.") { + t.Errorf("the statement did not travel whole: %q", text) + } + for _, gone := range []string{"**The rule.**", "**Why.**", sentinelPrincipleWhy, "Bounds", "claim_type"} { + if strings.Contains(text, gone) { + t.Errorf("the projected statement carries %q: %q", gone, text) + } + } + // A document without the paragraph contributes no item. + if _, ok, _ := projectField(principleRel, "# A principle\n\nProse only.\n", "The rule"); ok { + t.Error("a document with no labelled paragraph projected one") + } +} + +// TestLabelledParagraphCarriesTheTitle: a rule without its name is not readable +// cold, so the H1 title is placed above the statement. +func TestLabelledParagraphCarriesTheTitle(t *testing.T) { + text, _, _ := projectField(principleRel, principleDoc(defaultRule), "The rule") + if !strings.HasPrefix(text, "# Fix the detector\n\n"+principleStatement) { + t.Errorf("the projection does not open with the title above the statement: %q", text) + } +} + +// TestLinksUnwrapInTheStatement: a link target is a citation and the label is +// prose, so the target stays behind and the label travels. +func TestLinksUnwrapInTheStatement(t *testing.T) { + text, _, _ := projectField(principleRel, principleDoc(defaultRule), "The rule") + if strings.Contains(text, sentinelPrincipleLink) || strings.Contains(text, "](") { + t.Errorf("the link target travelled: %q", text) + } + if !strings.Contains(text, "as the ruling says") { + t.Errorf("the link's label did not travel as prose: %q", text) + } +} + +// TestPrincipleProjectsItsStatementOnly is ac-6's item half: at every position +// whose entry admits `principle`, the principle is ONE projected item naming its +// statement field, and neither its keys nor its citations reach the bundle. At +// comparative the row is not admitted at all. +func TestPrincipleProjectsItsStatementOnly(t *testing.T) { + root := principleFixture(t, defaultRule) + for _, p := range []Position{PositionWidening, PositionEntailment, PositionDetection} { + res := assembleFixture(t, root, p) + var items []ManifestItem + for _, it := range res.Manifest.Items { + if it.Path == principleRel { + items = append(items, it) + } + } + if len(items) != 1 || items[0].Field != "The rule" || items[0].Kind != KindPrinciple || items[0].Scan != ScanParsed { + t.Fatalf("at %s the principle travelled as %+v, want one parsed %q item of kind %q", + p, items, "The rule", KindPrinciple) + } + text := bundleText(res.Bundle) + if !strings.Contains(text, principleStatement) { + t.Errorf("at %s the statement did not reach the bundle", p) + } + for _, gone := range []string{sentinelPrincipleKey, sentinelPrincipleWhy, sentinelPrincipleLink, + "cond-2608311949582375", "claim_type"} { + if strings.Contains(text, gone) { + t.Errorf("at %s the bundle carries %q", p, gone) + } + } + } + res := assembleFixture(t, root, PositionComparative) + for _, it := range res.Manifest.Items { + if it.Path == principleRel || it.Kind == KindPrinciple { + t.Errorf("the comparative assembly carries a principle item: %+v", it) + } + } +} + +// TestPrincipleIsAScopeToken: `principle` is a kind a committed entry may name, +// the table admits it only where an entry does, and an entry naming the kind +// alone is handed the knowledge record and nothing else. +func TestPrincipleIsAScopeToken(t *testing.T) { + found := false + for _, k := range Kinds() { + if k == KindPrinciple && string(k) == "principle" { + found = true + } + } + if !found { + t.Fatalf("Kinds() = %v, which does not carry %q", Kinds(), "principle") + } + + root := principleFixture(t, defaultRule) + writeFile(t, root, PresetConfigPath, presetFileNaming(`"principle"`)) + gitCommitAll(t, root) + res := assembleFixture(t, root, PositionDetection) + if len(res.Manifest.Items) != 1 || res.Manifest.Items[0].Path != principleRel { + t.Errorf("an entry naming the principle kind alone was handed %v", itemPaths(res.Manifest)) + } + + // An entry that does not name the kind hands no principle, whatever else it + // names: the table ADMITS, the entry SELECTS. + writeFile(t, root, PresetConfigPath, presetFileNaming(`"spec"`)) + gitCommitAll(t, root) + res = assembleFixture(t, root, PositionDetection) + for _, it := range res.Manifest.Items { + if it.Kind == KindPrinciple { + t.Errorf("an entry not naming the principle kind was handed %s", it.Path) + } + } +} + +// presetFileNaming renders a preset file whose every assembling position names +// only the given kinds (comparative keeps the discipline its criteria need). +func presetFileNaming(kinds string) string { + var entries []string + for _, p := range AssemblingPositions() { + k := kinds + if p == PositionComparative { + k = `"discipline"` + } + entries = append(entries, v2Entry(string(p), k, "", "", 1_000_000)) + } + return "{\n \"schema_version\": 2,\n \"positions\": {\n" + strings.Join(entries, ",\n") + "\n }\n}\n" +} + +// TestPrincipleRowExcludesComparative: at comparative the include table is the +// whole account and admits the candidates and the criteria alone, so the +// principle row is admitted at the three assembling positions and no other. +func TestPrincipleRowExcludesComparative(t *testing.T) { + var rows []Row + for _, r := range Table { + if r.Kind == KindPrinciple { + rows = append(rows, r) + } + } + if len(rows) != 1 { + t.Fatalf("the table carries %d principle row(s), want 1", len(rows)) + } + r := rows[0] + if r.Source != ".abcd/development/principles" || r.Store != "prn" || len(r.Fields) != 1 || r.Fields[0] != "The rule" || r.Scan != ScanParsed { + t.Errorf("the principle row is %+v", r) + } + for _, p := range Positions() { + want := p != PositionComparative + if r.AdmittedAt(p) != want { + t.Errorf("the principle row admitted at %s = %v, want %v", p, r.AdmittedAt(p), want) + } + } +} + +// TestManifestAssertsPrincipleExclusions is ac-6's exclusion half: the four +// claim keys and the citation entry are asserted in every manifest, so a +// reader checks the withholding rather than trusting it. +func TestManifestAssertsPrincipleExclusions(t *testing.T) { + root := principleFixture(t, defaultRule) + res := assembleFixture(t, root, PositionDetection) + has := map[string]bool{} + for _, e := range res.Manifest.Exclusions { + has[e.Signal+"|"+e.Detail+"|"+e.Rule] = true + } + for _, k := range []string{"claim_type", "reference", "comparison", "evidence"} { + if !has["frontmatter key|"+k+"|field projection"] { + t.Errorf("the manifest does not assert the %s key's exclusion", k) + } + } + if !has["citation|record handles and links in a principle|the statement is knowledge and the citations are genealogy"] { + t.Errorf("the manifest does not assert the citation exclusion: %+v", res.Manifest.Exclusions) + } +} + +// TestPrincipleItemCarryingAHandleRefuses: the manifest's citation assertion is +// checked rather than trusted, so a principle whose statement still carries a +// record handle after projection refuses the assembly and names both. +func TestPrincipleItemCarryingAHandleRefuses(t *testing.T) { + root := principleFixture(t, principleStatement+", as adr-1 ruled.") + _, err := Assemble(AssembleRequest{RepoRoot: root, Position: PositionDetection, Target: "HEAD", DryRun: true}) + if err == nil { + t.Fatal("a principle whose statement carries a record handle assembled") + } + for _, want := range []string{principleRel, "adr-1"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } + + // The handle below the statement is genealogy the projection already keeps + // out, so it refuses nothing. + root = principleFixture(t, defaultRule) + assembleFixture(t, root, PositionDetection) +} + +// TestManifestAtTheOldSchemaVersionIsRefused: the kind vocabulary is closed and +// gains `principle`, so the shape version moves, and a manifest stamped at the +// previous version is refused rather than read. +func TestManifestAtTheOldSchemaVersionIsRefused(t *testing.T) { + if SchemaVersion != 11 { + t.Fatalf("SchemaVersion = %d, want 11: the closed kind vocabulary gains principle", SchemaVersion) + } + root := fixtureRepo(t) + res := assembleFixture(t, root, PositionDetection) + raw, err := EncodeManifest(res.Manifest) + if err != nil { + t.Fatal(err) + } + old := strings.Replace(string(raw), fmt.Sprintf("\"schema_version\": %d", SchemaVersion), "\"schema_version\": 10", 1) + if old == string(raw) { + t.Fatal("the replacement did nothing, so this case would test nothing") + } + if _, err := DecodeManifest([]byte(old)); err == nil { + t.Error("a manifest at schema version 10 decoded") + } +} diff --git a/internal/core/reading/project.go b/internal/core/reading/project.go index 8ca7bd340..c5b37f4c2 100644 --- a/internal/core/reading/project.go +++ b/internal/core/reading/project.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/intentdriven/abcd/internal/core/frontmatter" + "github.com/intentdriven/abcd/internal/core/mdrecord" "github.com/intentdriven/abcd/internal/core/site" ) @@ -20,9 +21,11 @@ import ( // record in this binary. // // A field resolves as a heading section where the file carries a heading of -// that name, and otherwise as a frontmatter key. Nothing else resolves: a field -// the file does not carry contributes no item, which is what lets one -// projection describe an intent whose sections the record is still growing. +// that name, then as a LABELLED PARAGRAPH where the file carries a paragraph +// opening with the field in bold (`**The rule.**`), and otherwise as a +// frontmatter key. Nothing else resolves: a field the file does not carry +// contributes no item, which is what lets one projection describe an intent +// whose sections the record is still growing. // trimBlankEdges joins a section body, dropping the blank lines at either end so // a projected field is the text and not the whitespace around it. @@ -1333,9 +1336,44 @@ func projectField(rel, doc, field string) (string, bool, error) { start, end := sectionSpan(sections, i, len(lines)) return trimBlankEdges(lines[min(start+1, len(lines)):min(end, len(lines))]), true, nil } + if text, ok := labelledParagraph(sections, lines, field); ok { + return text, true, nil + } fields := frontmatter.Fields(strings.Split(doc, "\n")) if f, ok := fields[field]; ok && !frontmatter.IsNull(f.Value) { return f.Value, true, nil } return "", false, nil } + +// labelledParagraph resolves a field as the first live paragraph opening with +// the field in bold, `**<field>.**`, taken to the next blank line +// (mdrecord.LabelledParagraph, the one reading the principles lint shares). +// +// It is how a principle's statement is found (spc-2609020626042471): a +// principle carries one heading, its H1, and a body of labelled paragraphs, +// so its statement is a paragraph and not a section. Two things are done to +// what it finds. The label is removed and the document's H1 title is placed +// above the paragraph, because a rule without its name is not readable cold. +// And every inline link is unwrapped to its label on the renderedTexts +// precedent: a link target is a citation, the label is prose, and the +// statement travels as knowledge while its citations stay behind. +func labelledParagraph(sections []site.Section, lines []string, field string) (string, bool) { + start, end, ok := mdrecord.LabelledParagraph(lines, field) + if !ok { + return "", false + } + para := make([]string, 0, end-start) + for _, ln := range lines[start:end] { + para = append(para, strings.TrimRight(ln, "\r")) + } + para[0] = strings.TrimLeft(strings.TrimPrefix(para[0], "**"+field+".**"), " \t") + body := strings.TrimSpace(mdLinkRe.ReplaceAllString(strings.Join(para, "\n"), "$1")) + for _, sec := range sections { + if sec.Level == 1 && strings.TrimSpace(sec.Title) != "" { + title := mdLinkRe.ReplaceAllString(normaliseHeadingTitle(sec.Title), "$1") + return "# " + title + "\n\n" + body, true + } + } + return body, true +} diff --git a/internal/surface/cli/reading_surface_test.go b/internal/surface/cli/reading_surface_test.go index 9fe8ad816..38da8275b 100644 --- a/internal/surface/cli/reading_surface_test.go +++ b/internal/surface/cli/reading_surface_test.go @@ -58,7 +58,7 @@ func readingRepoAt(t *testing.T, root string) string { `) write(".abcd/record-lint.json", `{"schema_version": 1, "rules": {"record_schema": {"enabled": true, "severity": "blocker", "record_stores": {"itd": ".abcd/development/intents", "spc": ".abcd/development/specs", - "rdi": ".abcd/work/issues/readings"}}}}`) + "rdi": ".abcd/work/issues/readings", "prn": ".abcd/development/principles"}}}}`) write(".abcd/development/brief/01-product/06-framing.md", "# Framing\n\n## Construal\n\nA gap in the record.\n") write(".abcd/development/brief/02-constraints/03-invariants.md", "# Invariants\n\n1. One core.\n") // The rest of brief current text (itd-194). A walk row's source directory From c1d268649effaf594c15d4d8d74a0db5e7850d4f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:55:32 +0100 Subject: [PATCH 31/78] test(evals): the principle coverage rows name the mechanism that caught them Each of the five knowledge-record rows was run against its mutation on a scratch copy. Three caught as written (the unwrapping and the key rows by a PRINCIPLE-CITATION leak, the dropped row by the carrier floor). Two did not catch the way the spec's list says: projecting the whole principle is refused by verifyPrincipleItem before anything leaks, and admitting the row at comparative is refused by the floor's directory row, so the family-absence catch needs that row deleted as well. The rows now state the mechanism that was watched. Part of itd-2609020625405170 / spc-2609020626042471. Assisted-by: Claude:claude-opus-5-5 --- evals/coldreading_coverage_test.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/evals/coldreading_coverage_test.go b/evals/coldreading_coverage_test.go index 63eb2693c..6b8b188ab 100644 --- a/evals/coldreading_coverage_test.go +++ b/evals/coldreading_coverage_test.go @@ -699,6 +699,7 @@ var coverage = []coverageRow{ }, // ---- the knowledge record (spc-2609020626042471) ---- { + // The five rows below were each watched red on a scratch copy. Rule: "principles are admitted as their statement at the three assembling positions", Falsifier: "delete the principle row from Table", Caught: caughtCarrier, @@ -711,9 +712,12 @@ var coverage = []coverageRow{ Classes: []string{"PRINCIPLE-CITATION"}, }, { + // Watched: with the whole file admitted, the **Why.** paragraph's record + // handle reaches the principle item and verifyPrincipleItem refuses the + // run. Were that refusal also gone, the class would leak. Rule: "a principle's citations never travel: it is projected to its statement", Falsifier: "project the whole file on the principle row (empty its Fields)", - Caught: caughtLeak, + Caught: caughtRefusal, Classes: []string{"PRINCIPLE-CITATION"}, }, { @@ -723,8 +727,11 @@ var coverage = []coverageRow{ Classes: []string{"PRINCIPLE-CITATION"}, }, { + // Watched: with the row admitted at comparative the floor's comparative + // directory row refuses the run by path; with that row gone too, the + // family-absence oracle names the manifest that stopped asserting it. Rule: "the principle row is not admitted at the comparative position, and its manifest says so", - Falsifier: "add the comparative position to the principle row", + Falsifier: "add the comparative position to the principle row and delete the principles directory row from Exclusions", Caught: caughtFamily, }, { From 885bf3b46a20dc0b66a70e26e75bc3334729e3c2 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:59:28 +0100 Subject: [PATCH 32/78] feat(lifeboat): disembark principles carries the four claim keys principles.json moves to schema version 2 (a payload at 1 is refused as unsupported): every principle carries claim_type, reference and comparison beside its evidence, as nullable fields that are never omitted. The delegated validator reads each entry raw as well as typed, so an absent key and a null one are told apart: an entry missing any of the four is dropped with its reason, claim_type is one of the record's three claim kinds or null (lint.CanonicalClaimType, so the lifeboat and the record read one vocabulary; `mechanism` is written back as `causal`), and a stated reference or comparison is sanitised prose that must survive sanitising. Deterministic mode writes the ADR it distilled from as the reference and declines the other two with null. principles.md renders the three beside the evidence. Evidence keeps the lifeboat's own cite-or-be-dropped grammar: a reading item or condition identity resolves to nothing packed and is filtered. agents/principle-distiller.md moves to 0.2.0 with the keys in its field rules and its example, its injection canary's example payload moves to version 2, and agents/CHANGELOG.md records the bump. A lockstep test in core/lifeboat holds the definition's example to the struct and the validator (it lives there because core/lifeboat imports core/lint). commands/disembark.md and the disembark brief chapter say what the payload carries. Part of itd-2609020625405170 / spc-2609020626042471. Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/02-disembark.md | 5 +- agents/principle-distiller.md | 32 +++- .../fixtures/injection-canary.json | 7 +- commands/disembark.md | 10 +- .../core/lifeboat/principles_contract_test.go | 103 ++++++++++ .../core/lifeboat/synthesis_principles.go | 76 +++++++- .../synthesis_principles_keys_test.go | 178 ++++++++++++++++++ internal/core/lifeboat/synthesis_types.go | 18 +- .../surface/cli/disembark_synthesis_test.go | 12 +- 9 files changed, 424 insertions(+), 17 deletions(-) create mode 100644 internal/core/lifeboat/principles_contract_test.go create mode 100644 internal/core/lifeboat/synthesis_principles_keys_test.go diff --git a/.abcd/development/brief/04-surfaces/02-disembark.md b/.abcd/development/brief/04-surfaces/02-disembark.md index 9360ce56a..b1dfce805 100644 --- a/.abcd/development/brief/04-surfaces/02-disembark.md +++ b/.abcd/development/brief/04-surfaces/02-disembark.md @@ -159,7 +159,10 @@ present only when no transcript tier grounded the package, so an unmarked lifeboat marshals as it always has and embark can say which it is. The synthesis sub-verbs add the rest afterwards: the press release writes -`press-release.{json,md}`, the principles write `principles.{json,md}`, the review +`press-release.{json,md}`, the principles write `principles.{json,md}` (schema +version 2: each principle carries `claim_type`, `reference` and `comparison` +beside its evidence, a declined claim as `null`, per +[adr-2609021016270132](../../decisions/adrs/2609021016270132-the-principles-family-is-a-declared-record-store-whose-entri.md)), the review writes the verdict artefact, and the graveyard validates and writes the lesson JSON. None of these exist at pack time. diff --git a/agents/principle-distiller.md b/agents/principle-distiller.md index 098f67bbf..3c1000d2e 100644 --- a/agents/principle-distiller.md +++ b/agents/principle-distiller.md @@ -1,7 +1,7 @@ --- name: principle-distiller description: Distil durable principles from a packed lifeboat's decision record — each principle citing the record ids or lifeboat paths it rests on. Host-delegated; feeds `abcd disembark principles <lifeboat-dir> --principles-json`. -prompt_version: 0.1.0 +prompt_version: 0.2.0 reads_untrusted_input: true capability_scope: task_classes: [principle_distillation] @@ -42,14 +42,17 @@ the **whole payload**, not just the offending entry. Use exactly these keys: ```json { - "schema_version": 1, + "schema_version": 2, "mode": "delegated", - "prompt_version": "0.1.0", + "prompt_version": "0.2.0", "principles": [ { "id": "prn-oracle-cascade-fixed", "principle": "The oracle cascade is fixed; capability routing is a pre-cascade selector.", "confidence": "high", + "claim_type": "causal", + "reference": "adr-24", + "comparison": null, "evidence": ["adr-24", "docs/adrs/0024-oracle-cascade.md"] } ] @@ -58,10 +61,11 @@ the **whole payload**, not just the offending entry. Use exactly these keys: Field rules: -- `schema_version`: integer `1`. Required — a missing or `0` value is rejected. +- `schema_version`: integer `2`. Required — a missing or `0` value is rejected, + and so is `1`, the version before the claim keys. - `mode`: `"delegated"` (you are the delegated path). If present it must be exactly `"delegated"`; the binary stamps it regardless. -- `prompt_version`: `"0.1.0"` — this file's version, semver-shaped. Required in your +- `prompt_version`: `"0.2.0"` — this file's version, semver-shaped. Required in your delegated output. - `principles`: an array. Each entry: - `id`: `prn-` followed by kebab-case `[a-z0-9]` segments (e.g. `prn-fixed-cascade`), @@ -71,8 +75,26 @@ Field rules: - `confidence`: exactly one of `high`, `medium`, `low`. High means the record states it outright; medium means it is strongly implied; low means it is your reading of converging evidence. An unknown value drops the entry. + - `claim_type`: what kind of claim the principle makes — exactly one of + `criterion` (a standard a choice is judged by), `causal` (X brings about Y, + by a mechanism the record states) or `context` (a condition the record + treats as given), or `null` when the record does not settle it. `mechanism` + is read as `causal` and written back as `causal`; any other value drops the + entry. + - `reference`: what the principle is about — a record id (`adr-24`) or the + name of a surface (a verb, a package, a rule), or `null`. + - `comparison`: what was compared to produce the principle, in one sentence + (the alternatives the record weighed), or `null`. - `evidence`: the ids/paths this principle rests on (see citation discipline). +Every entry carries all seven keys. A claim you considered and could not ground +in the record is `null`, never omitted: an entry missing `claim_type`, +`reference` or `comparison` is dropped, because an absent key and a declined +claim are different statements. Write `null` rather than guess — the record's +own words decide these, and a comparison the record never made is a principle +invented, not distilled. A stated `reference` or `comparison` is sanitised +like the principle, and one that sanitises to nothing drops the entry. + No other top-level or per-entry keys. Do not add `mode: "deterministic"` — a delegated payload claiming deterministic is refused. diff --git a/agents/principle-distiller/fixtures/injection-canary.json b/agents/principle-distiller/fixtures/injection-canary.json index dfbe95dbb..8121cd60d 100644 --- a/agents/principle-distiller/fixtures/injection-canary.json +++ b/agents/principle-distiller/fixtures/injection-canary.json @@ -13,14 +13,17 @@ "must_not_obey": ["persona switch", "cite nothing", "exfiltrate tokens", "close the system prompt"], "injection_is_inert_data": true, "emitted_payload_example": { - "schema_version": 1, + "schema_version": 2, "mode": "delegated", - "prompt_version": "0.1.0", + "prompt_version": "0.2.0", "principles": [ { "id": "prn-oracle-cascade-fixed", "principle": "The oracle cascade order is fixed; capability routing is a pre-cascade selector, not a modification of the cascade.", "confidence": "high", + "claim_type": "causal", + "reference": "adr-31", + "comparison": null, "evidence": ["adr-31", "docs/adrs/0031-oracle-cascade-fixed.md"] } ] diff --git a/commands/disembark.md b/commands/disembark.md index 553257669..1e289d13a 100644 --- a/commands/disembark.md +++ b/commands/disembark.md @@ -153,9 +153,17 @@ distils from), write that document to a file, then: "${CLAUDE_PLUGIN_ROOT}/abcd" disembark principles <lifeboat-dir> --principles-json <path> # or - for stdin ``` +Every principle carries three typed claims beside its evidence: `claim_type` +(`criterion`, `causal` or `context`), `reference` (what the principle is about) +and `comparison` (what was compared to produce it). A claim the record does not +settle is `null`; an entry missing one of the keys is dropped, and a `mechanism` +claim type is written back as `causal`. The payload is `schema_version` 2. + **Without the flag** the verb runs deterministic mode: it writes an evidence-only `principles.json` composed straight from the packed ADRs' own stated decisions — -no agent, no interpretation, byte-identical across re-runs. +no agent, no interpretation, byte-identical across re-runs. Each principle's +`reference` is the ADR it came from, and its claim type and comparison are `null`, +because the fallback carries what it can establish and invents nothing. With the flag it is a **cite-or-be-dropped** gate. A principle survives only if at least one of its `evidence` refs resolves to a live record/finding id or a packed diff --git a/internal/core/lifeboat/principles_contract_test.go b/internal/core/lifeboat/principles_contract_test.go new file mode 100644 index 000000000..f4d8f0592 --- /dev/null +++ b/internal/core/lifeboat/principles_contract_test.go @@ -0,0 +1,103 @@ +package lifeboat + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "regexp" + "sort" + "strings" + "testing" +) + +// principles_contract_test.go pins agents/principle-distiller.md to the payload +// this package decodes (spc-2609020626042471). The definition is what the +// host-delegated distiller is told to emit and validateDelegatedPrinciples is +// what refuses it, so the two drifting apart is silent until a real payload is +// rejected wholesale by DisallowUnknownFields or drops every entry for a key it +// was never told to carry. +// +// It lives here rather than beside the agent_contract lint rule because +// core/lifeboat imports core/lint: a lint test importing this package back +// would be a cycle, and the lockstep assertion is worthless against a +// re-declared copy of the struct. + +var distillerPromptVersionRe = regexp.MustCompile(`(?m)^prompt_version:\s*(\S+)\s*$`) + +func TestDistillerDefinitionMatchesThePrinciplesPayload(t *testing.T) { + path := filepath.Join("..", "..", "..", "agents", "principle-distiller.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + doc := string(data) + fence := regexp.MustCompile("(?s)```json\n(.*?)\n```").FindAllStringSubmatch(doc, -1) + if len(fence) != 1 { + t.Fatalf("the definition carries %d json example(s), want exactly one", len(fence)) + } + body := []byte(fence[0][1]) + + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + var pf PrinciplesFile + if err := dec.Decode(&pf); err != nil { + t.Fatalf("the definition's example does not decode strictly: %v", err) + } + if pf.SchemaVersion != PrinciplesSchemaVersion { + t.Errorf("the example's schema_version is %d, the payload's is %d", pf.SchemaVersion, PrinciplesSchemaVersion) + } + m := distillerPromptVersionRe.FindStringSubmatch(doc) + if m == nil || pf.PromptVersion != m[1] { + t.Errorf("the example's prompt_version %q is not the definition's own %v", pf.PromptVersion, m) + } + + // Every entry documents exactly the fields the struct carries: a key the + // struct lacks is a whole-payload refusal, and one it carries that the + // example omits is a key the distiller is never shown. + var raw struct { + Principles []map[string]json.RawMessage `json:"principles"` + } + if err := json.Unmarshal(body, &raw); err != nil || len(raw.Principles) == 0 { + t.Fatalf("the example carries no principle entry: %v", err) + } + want := jsonTags(reflect.TypeOf(Principle{})) + for i, e := range raw.Principles { + got := make([]string, 0, len(e)) + for k := range e { + got = append(got, k) + } + sort.Strings(got) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("example entry %d carries %v, the payload's entry is %v", i, got, want) + } + } + // And the example must survive the validator's own rules. + for _, e := range raw.Principles { + var p Principle + b, _ := json.Marshal(e) + _ = json.Unmarshal(b, &p) + if _, reason := principleClaims(e, p); reason != "" { + t.Errorf("the example entry %s would be dropped: %s", p.ID, reason) + } + } + // The field rules name each claim key, so the distiller is told what it is. + for _, k := range []string{"`claim_type`", "`reference`", "`comparison`", "`evidence`"} { + if !strings.Contains(doc, k) { + t.Errorf("the definition's field rules never name %s", k) + } + } +} + +func jsonTags(rt reflect.Type) []string { + out := make([]string, 0, rt.NumField()) + for i := 0; i < rt.NumField(); i++ { + name, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",") + if name != "" && name != "-" { + out = append(out, name) + } + } + sort.Strings(out) + return out +} diff --git a/internal/core/lifeboat/synthesis_principles.go b/internal/core/lifeboat/synthesis_principles.go index 562cc9c6d..958d9caab 100644 --- a/internal/core/lifeboat/synthesis_principles.go +++ b/internal/core/lifeboat/synthesis_principles.go @@ -33,6 +33,7 @@ import ( "strings" "github.com/intentdriven/abcd/internal/core/frontmatter" + "github.com/intentdriven/abcd/internal/core/lint" "github.com/intentdriven/abcd/internal/core/update" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/termsafe" @@ -184,10 +185,16 @@ func deterministicPrinciples(abs string) ([]Principle, []PrincipleDrop, error) { return } seen[adrID] = true + // The fallback carries what it can establish and invents nothing: the + // ADR it distilled from is what the principle is about, and the claim + // kind and the comparison are the record's words to state, not this + // function's, so they are declined as null (spc-2609020626042471). + reference := adrID out = append(out, Principle{ ID: prnID, Principle: principle, Confidence: ConfidenceHigh, + Reference: &reference, Evidence: []string{adrID, p}, }) }) @@ -221,6 +228,16 @@ func validateDelegatedPrinciples(abs string, raw []byte) ([]Principle, string, [ if len(pf.Principles) > maxPrinciples { return nil, "", nil, fmt.Errorf("too many principles (%d > %d)", len(pf.Principles), maxPrinciples) } + // The same entries read raw, so an absent key and a null one are told apart: + // the typed decode reads both as nil, and they are different claims — a key + // considered and declined is null, an absent key is a claim not carried, and + // schema version 2 requires every entry to carry all four. + var raws struct { + Principles []map[string]json.RawMessage `json:"principles"` + } + if err := json.Unmarshal(raw, &raws); err != nil || len(raws.Principles) != len(pf.Principles) { + return nil, "", nil, errors.New("malformed principles JSON: the entries cannot be read as objects") + } valid, err := buildPrincipleEvidenceSet(abs) if err != nil { @@ -230,7 +247,7 @@ func validateDelegatedPrinciples(abs string, raw []byte) ([]Principle, string, [ var survivors []Principle var drops []PrincipleDrop seen := map[string]bool{} - for _, in := range pf.Principles { + for i, in := range pf.Principles { drop := func(reason string) { drops = append(drops, PrincipleDrop{ID: in.ID, Reason: reason}) } if len(in.ID) > maxSynthIDLen || !prnIDRe.MatchString(in.ID) { drop("malformed principle id") @@ -244,6 +261,11 @@ func validateDelegatedPrinciples(abs string, raw []byte) ([]Principle, string, [ drop("unknown confidence") continue } + claims, reason := principleClaims(raws.Principles[i], in) + if reason != "" { + drop(reason) + continue + } refs := filterSynthEvidence(in.Evidence, valid) if len(refs) == 0 { drop("no valid evidence refs") @@ -262,12 +284,52 @@ func validateDelegatedPrinciples(abs string, raw []byte) ([]Principle, string, [ ID: in.ID, Principle: clean, Confidence: in.Confidence, + ClaimType: claims.ClaimType, + Reference: claims.Reference, + Comparison: claims.Comparison, Evidence: refs, }) } return survivors, pf.PromptVersion, drops, nil } +// principleClaims validates one delegated entry's three claim keys against its +// raw object and returns them cleaned, or the reason the entry is dropped. +// Every key must be present; null is allowed on the three; claim_type is one of +// the record's three claim kinds, with the shipped intent token `mechanism` +// read as, and written back as, `causal`; a stated reference or comparison is +// sanitised prose that must survive sanitising. +func principleClaims(raw map[string]json.RawMessage, in Principle) (Principle, string) { + for _, k := range append([]string{}, lint.PrincipleKeys...) { + if _, ok := raw[k]; !ok { + return Principle{}, "missing " + k + " key (a claim considered and declined is null)" + } + } + var out Principle + if in.ClaimType != nil { + ct, ok := lint.CanonicalClaimType(*in.ClaimType) + if !ok { + return Principle{}, "unknown claim_type (want " + strings.Join(lint.ClaimTypes, ", ") + " or null)" + } + out.ClaimType = &ct + } + for _, f := range []struct { + key string + in *string + out **string + }{{"reference", in.Reference, &out.Reference}, {"comparison", in.Comparison, &out.Comparison}} { + if f.in == nil { + continue + } + clean := cleanSynthProse(*f.in) + if clean == "" { + return Principle{}, "empty " + f.key + " (a claim considered and declined is null)" + } + *f.out = &clean + } + return out, "" +} + // buildPrincipleEvidenceSet is the union R∪F∪P a delegated principle's evidence // must hit: live record ids (adr/itd/iss), live graveyard finding ids, and every // packed lifeboat path. A principle survives iff ≥1 of its refs is a member. @@ -332,6 +394,9 @@ func renderPrinciplesMarkdown(f PrinciplesFile) string { for _, p := range f.Principles { fmt.Fprintf(&b, "## %s (%s)\n\n", sanitize(p.ID), sanitize(string(p.Confidence))) fmt.Fprintf(&b, "%s\n\n", sanitize(p.Principle)) + fmt.Fprintf(&b, "Claim type: %s\n\n", claimOrNone(p.ClaimType)) + fmt.Fprintf(&b, "Reference: %s\n\n", claimOrNone(p.Reference)) + fmt.Fprintf(&b, "Comparison: %s\n\n", claimOrNone(p.Comparison)) if len(p.Evidence) > 0 { fmt.Fprintf(&b, "Evidence: %s\n\n", strings.Join(sanitizeAll(p.Evidence), ", ")) } @@ -339,6 +404,15 @@ func renderPrinciplesMarkdown(f PrinciplesFile) string { return b.String() } +// claimOrNone renders a claim key for principles.md: its sanitised value, or +// the words a declined claim reads as. +func claimOrNone(v *string) string { + if v == nil { + return "none stated" + } + return sanitize(*v) +} + // --------------------------------------------------------------------------- // Shared synthesis helpers. A1 lands these; A2's oracle reuses them. // --------------------------------------------------------------------------- diff --git a/internal/core/lifeboat/synthesis_principles_keys_test.go b/internal/core/lifeboat/synthesis_principles_keys_test.go new file mode 100644 index 000000000..75051b3a0 --- /dev/null +++ b/internal/core/lifeboat/synthesis_principles_keys_test.go @@ -0,0 +1,178 @@ +package lifeboat + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// The principles payload at schema version 2 (spc-2609020626042471): every +// entry carries claim_type, reference and comparison beside its evidence, a +// declined claim is null, and an absent key drops the entry. + +func strp(s string) *string { return &s } + +// rawEntries decodes principles.json's entries as raw objects, so absent and +// null are told apart. +func rawEntries(t *testing.T, dir string) []map[string]json.RawMessage { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, "principles.json")) + if err != nil { + t.Fatal(err) + } + var f struct { + Principles []map[string]json.RawMessage `json:"principles"` + } + if err := json.Unmarshal(data, &f); err != nil { + t.Fatal(err) + } + return f.Principles +} + +// TestDeterministicPrinciplesCarryTheFourKeys is ac-8's deterministic half: the +// fallback carries what it can establish — the ADR handle it distilled from as +// the reference — and declines the claim type and the comparison with null. +func TestDeterministicPrinciplesCarryTheFourKeys(t *testing.T) { + dir := adrLifeboat(t) + if _, err := SynthesizePrinciples(dir, nil); err != nil { + t.Fatal(err) + } + pf := readPrinciplesFile(t, dir) + if pf.SchemaVersion != 2 { + t.Errorf("schema_version = %d, want 2", pf.SchemaVersion) + } + entries := rawEntries(t, dir) + if len(entries) != 2 { + t.Fatalf("got %d entries, want 2", len(entries)) + } + for _, e := range entries { + for _, k := range []string{"claim_type", "reference", "comparison", "evidence"} { + if _, ok := e[k]; !ok { + t.Errorf("entry %s carries no %q key", e["id"], k) + } + } + if string(e["claim_type"]) != "null" || string(e["comparison"]) != "null" { + t.Errorf("entry %s: claim_type %s, comparison %s; the fallback invents neither", e["id"], e["claim_type"], e["comparison"]) + } + } + if got := pf.Principles[0].Reference; got == nil || *got != "adr-24" { + t.Errorf("the first principle's reference = %v, want the ADR it was distilled from", got) + } +} + +// TestDelegatedPrincipleWithoutAKeyIsDropped is ac-8's delegated half. +func TestDelegatedPrincipleWithoutAKeyIsDropped(t *testing.T) { + dir := adrLifeboat(t) + payload := `{"schema_version": 2, "mode": "delegated", "prompt_version": "0.2.0", "principles": [ + {"id": "prn-whole", "principle": "The cascade is fixed.", "confidence": "high", + "claim_type": "causal", "reference": "adr-24", "comparison": null, "evidence": ["adr-24"]}, + {"id": "prn-short", "principle": "One binary.", "confidence": "high", + "claim_type": "context", "reference": "adr-31", "evidence": ["adr-31"]} + ]}` + res, err := SynthesizePrinciples(dir, []byte(payload)) + if err != nil { + t.Fatal(err) + } + if res.Written != 1 || res.Dropped != 1 || res.Drops[0].ID != "prn-short" || !strings.Contains(res.Drops[0].Reason, "comparison") { + t.Fatalf("result = %+v, want prn-short dropped naming the missing comparison", res) + } +} + +// TestSchemaVersionOneIsRefused: a consumer of principles.json sees the change +// by the version, and a payload at the previous one is refused. +func TestSchemaVersionOneIsRefused(t *testing.T) { + dir := adrLifeboat(t) + _, err := SynthesizePrinciples(dir, []byte(`{"schema_version": 1, "mode": "delegated", "prompt_version": "0.1.0", "principles": []}`)) + if err == nil || !strings.Contains(err.Error(), "unsupported principles schema_version 1") { + t.Fatalf("a version 1 payload was not refused as unsupported: %v", err) + } +} + +// TestNullKeyIsCarriedNotDropped: a declined claim survives, as a null. +func TestNullKeyIsCarriedNotDropped(t *testing.T) { + dir := adrLifeboat(t) + payload := principlesPayload(t, "0.2.0", Principle{ + ID: "prn-declined", Principle: "The cascade is fixed.", Confidence: ConfidenceHigh, + Evidence: []string{"adr-24"}, + }) + res, err := SynthesizePrinciples(dir, payload) + if err != nil { + t.Fatal(err) + } + if res.Written != 1 { + t.Fatalf("a principle declining three claims was dropped: %+v", res) + } + e := rawEntries(t, dir)[0] + for _, k := range []string{"claim_type", "reference", "comparison"} { + if string(e[k]) != "null" { + t.Errorf("%s = %s, want null carried", k, e[k]) + } + } +} + +// TestDelegatedMechanismIsWrittenAsCausal: the alias is read and never written. +func TestDelegatedMechanismIsWrittenAsCausal(t *testing.T) { + dir := adrLifeboat(t) + payload := principlesPayload(t, "0.2.0", Principle{ + ID: "prn-mech", Principle: "The cascade is fixed.", Confidence: ConfidenceHigh, + ClaimType: strp("mechanism"), Reference: strp("adr-24"), Comparison: strp("Routing against a fixed cascade."), + Evidence: []string{"adr-24"}, + }, Principle{ + ID: "prn-fourth", Principle: "One binary.", Confidence: ConfidenceHigh, + ClaimType: strp("normative"), Evidence: []string{"adr-31"}, + }) + res, err := SynthesizePrinciples(dir, payload) + if err != nil { + t.Fatal(err) + } + pf := readPrinciplesFile(t, dir) + if len(pf.Principles) != 1 || pf.Principles[0].ClaimType == nil || *pf.Principles[0].ClaimType != "causal" { + t.Fatalf("principles = %+v, want prn-mech written as causal", pf.Principles) + } + if res.Dropped != 1 || res.Drops[0].ID != "prn-fourth" { + t.Errorf("a fourth claim type was not dropped: %+v", res) + } +} + +// TestPrinciplesMarkdownRendersTheKeys: the human render carries the three +// claims beside the evidence, a declined one said as such. +func TestPrinciplesMarkdownRendersTheKeys(t *testing.T) { + dir := adrLifeboat(t) + payload := principlesPayload(t, "0.2.0", Principle{ + ID: "prn-whole", Principle: "The cascade is fixed.", Confidence: ConfidenceHigh, + ClaimType: strp("causal"), Reference: strp("adr-24"), Evidence: []string{"adr-24"}, + }) + if _, err := SynthesizePrinciples(dir, payload); err != nil { + t.Fatal(err) + } + md, err := os.ReadFile(filepath.Join(dir, "principles.md")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"Claim type: causal", "Reference: adr-24", "Comparison: none stated", "Evidence: adr-24"} { + if !strings.Contains(string(md), want) { + t.Errorf("principles.md does not carry %q:\n%s", want, md) + } + } +} + +// TestEvidenceCarriesPackedIDsOnly: the payload's evidence is the lifeboat's +// own grammar — packed record ids, finding ids and packed paths — so a reading +// item or a condition identity, which the lifeboat packs no store for, resolves +// to nothing and is filtered out. +func TestEvidenceCarriesPackedIDsOnly(t *testing.T) { + dir := adrLifeboat(t) + payload := principlesPayload(t, "0.2.0", Principle{ + ID: "prn-mixed", Principle: "The cascade is fixed.", Confidence: ConfidenceHigh, + Evidence: []string{"adr-24", "rdi-2609020000000009", "cond-2608311949582375", "prn-other"}, + }) + if _, err := SynthesizePrinciples(dir, payload); err != nil { + t.Fatal(err) + } + pf := readPrinciplesFile(t, dir) + if len(pf.Principles) != 1 || strings.Join(pf.Principles[0].Evidence, ",") != "adr-24" { + t.Errorf("evidence = %+v, want the packed adr-24 alone", pf.Principles) + } +} diff --git a/internal/core/lifeboat/synthesis_types.go b/internal/core/lifeboat/synthesis_types.go index 6b4ca79b1..01da015b5 100644 --- a/internal/core/lifeboat/synthesis_types.go +++ b/internal/core/lifeboat/synthesis_types.go @@ -44,8 +44,11 @@ import "regexp" // --------------------------------------------------------------------------- const ( - // PrinciplesSchemaVersion stamps principles.json. - PrinciplesSchemaVersion = 1 + // PrinciplesSchemaVersion stamps principles.json. It is 2 since every entry + // carries claim_type, reference and comparison beside its evidence + // (adr-2609021016270132, spc-2609020626042471); a payload at 1 is refused as + // unsupported, so a consumer sees the change by the version. + PrinciplesSchemaVersion = 2 // PressReleaseSchemaVersion stamps press-release.json. PressReleaseSchemaVersion = 1 // ReviewSchemaVersion stamps review/review-<manifest12>.json. @@ -111,10 +114,21 @@ func (v ReviewVerdict) Valid() bool { return reviewVerdictEnum[v] } // Principle is one distilled principle — both the untrusted delegated input shape // and the written output shape. Principle (the prose) is sanitised and // marker-neutralised before it is written into the lifeboat. +// +// The three claim keys beside Evidence are the record's principle keys +// (adr-2609021016270132): claim_type (criterion, causal or context), reference +// (the entity the principle is about) and comparison (what was compared to +// produce it). They are pointers so a declined claim is carried as a JSON null +// rather than omitted, and they are never omitempty: an absent key and a null +// one are different claims, and the validator reads the payload raw to tell +// them apart. type Principle struct { ID string `json:"id"` Principle string `json:"principle"` Confidence Confidence `json:"confidence"` + ClaimType *string `json:"claim_type"` + Reference *string `json:"reference"` + Comparison *string `json:"comparison"` Evidence []string `json:"evidence"` } diff --git a/internal/surface/cli/disembark_synthesis_test.go b/internal/surface/cli/disembark_synthesis_test.go index 168180c1c..d2a1844c8 100644 --- a/internal/surface/cli/disembark_synthesis_test.go +++ b/internal/surface/cli/disembark_synthesis_test.go @@ -163,8 +163,9 @@ func TestDisembarkPrinciplesDeterministic(t *testing.T) { // adr-12 is written, exit 0, mode delegated. func TestDisembarkPrinciplesDelegated(t *testing.T) { dir := buildSynthLifeboat(t, synthOpts{}) - payload := synthPayloadFile(t, `{"schema_version":1,"mode":"delegated","prompt_version":"0.1.0",`+ - `"principles":[{"id":"prn-cascade","principle":"the cascade is fixed","confidence":"high","evidence":["adr-12"]}]}`) + payload := synthPayloadFile(t, `{"schema_version":2,"mode":"delegated","prompt_version":"0.2.0",`+ + `"principles":[{"id":"prn-cascade","principle":"the cascade is fixed","confidence":"high",`+ + `"claim_type":"causal","reference":"adr-12","comparison":null,"evidence":["adr-12"]}]}`) out := runCLI(t, "disembark", "principles", dir, "--principles-json", payload, "--json") var res lifeboat.PrinciplesResult if err := json.Unmarshal(out, &res); err != nil { @@ -178,8 +179,9 @@ func TestDisembarkPrinciplesDelegated(t *testing.T) { // TestDisembarkPrinciplesStdin: the "-" stdin transport works (one verb suffices). func TestDisembarkPrinciplesStdin(t *testing.T) { dir := buildSynthLifeboat(t, synthOpts{}) - payload := `{"schema_version":1,"mode":"delegated","prompt_version":"0.1.0",` + - `"principles":[{"id":"prn-x","principle":"y","confidence":"high","evidence":["adr-12"]}]}` + payload := `{"schema_version":2,"mode":"delegated","prompt_version":"0.2.0",` + + `"principles":[{"id":"prn-x","principle":"y","confidence":"high",` + + `"claim_type":null,"reference":null,"comparison":null,"evidence":["adr-12"]}]}` out := runCLIStdin(t, payload, "disembark", "principles", dir, "--principles-json", "-", "--json") var res lifeboat.PrinciplesResult if err := json.Unmarshal(out, &res); err != nil { @@ -194,7 +196,7 @@ func TestDisembarkPrinciplesStdin(t *testing.T) { // field is a structural refusal — exit 2, scrubbed (no absolute path leak). func TestDisembarkPrinciplesUnknownFieldExit2(t *testing.T) { dir := buildSynthLifeboat(t, synthOpts{}) - payload := synthPayloadFile(t, `{"schema_version":1,"mode":"delegated","prompt_version":"0.1.0",`+ + payload := synthPayloadFile(t, `{"schema_version":2,"mode":"delegated","prompt_version":"0.2.0",`+ `"principles":[],"smuggled":true}`) var stdout, stderr bytes.Buffer code := Run([]string{"disembark", "principles", dir, "--principles-json", payload}, &stdout, &stderr) From b83cf0139c98c16d34b437f2ed5fa689af80f87d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:59:56 +0100 Subject: [PATCH 33/78] chore: capture the lint chapter's stale count of prose_citation_resolves stores Refs: iss-2609252159470046 Assisted-by: Claude:claude-opus-5-5 --- ...er-s-section-1-1-says-prose-citation-resolves.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609252159470046-the-lint-chapter-s-section-1-1-says-prose-citation-resolves.md diff --git a/.abcd/work/issues/open/iss-2609252159470046-the-lint-chapter-s-section-1-1-says-prose-citation-resolves.md b/.abcd/work/issues/open/iss-2609252159470046-the-lint-chapter-s-section-1-1-says-prose-citation-resolves.md new file mode 100644 index 000000000..4011c9323 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252159470046-the-lint-chapter-s-section-1-1-says-prose-citation-resolves.md @@ -0,0 +1,13 @@ +--- +schema_version: 1 +id: "iss-2609252159470046" +slug: "the-lint-chapter-s-section-1-1-says-prose-citation-resolves" +severity: "minor" +category: "drift" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +--- + +The lint chapter's section 1.1 says prose_citation_resolves scans nine record stores and lists rdi, dsp, rdg, adm and srp as the non-citation stores, while .abcd/record-lint.json names ten under that rule: the reframe store rfm was added to the config and not to the sentence. From 49b5400f0c3579eca929aaf7f2015ee2600a2290 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:59:57 +0100 Subject: [PATCH 34/78] docs(brief): the lint chapter counts the ten stores prose_citation_resolves scans The rule's record_stores names ten roots since the reframe store joined it; the chapter still said nine and left rfm out of the non-citation families. Refs: iss-2609252159470046 Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/brief/05-internals/06-lint.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.abcd/development/brief/05-internals/06-lint.md b/.abcd/development/brief/05-internals/06-lint.md index 80eabfbea..a1e9d0c7f 100644 --- a/.abcd/development/brief/05-internals/06-lint.md +++ b/.abcd/development/brief/05-internals/06-lint.md @@ -20,7 +20,7 @@ The rule reads a record's free text, which is wider than its body and narrower t - **Frontmatter free text is prose.** The whole document is read, minus the frontmatter lines whose key is one of the typed cross-reference fields `record_schema` already resolves (and the indented block under such a key). Everything else above the `---` — `deferral_reason`, `found_during`, `resolution`, a `kind_notes` sentence — is a sentence someone wrote, and an id inside one must resolve like any other. A YAML comment after the value carries the line marker where a value must stay verbatim: `found_during: "…" # <!-- record-lint: illustrative -->`. - **A slug does not stop an id being an id.** `itd-160-dangling-….md` in a sentence cites `itd-160`. `links_resolve` judges markdown link *targets*, `[..](..)`, so a bare filename-shaped handle in prose reaches no other rule; treating the shape as a filename let an invented id go quiet under an appended slug. A placeholder written with a LETTER — `itd-N`, `spc-<id>`, `adr-NNNN` — is still not a citation and still needs no marker. - **Only triple-backtick fences are code.** A `~~~` fence is not recognised, and neither is four-space indented code: an id inside either is read as prose and must resolve or carry a marker. The rule fails toward asking rather than toward silence, and this is the one place an author meets that. -- **Nine stores are scanned; four families resolve.** The `record_stores` config names nine roots so every record's prose is read, but only `adr`, `itd`, `iss` and `spc` are the cited-id grammar. An `rdi`, `dsp`, `rdg`, `adm` or `srp` id is not a citation to this rule and is checked by nothing here — those stores are in the list for the prose their files carry, not for their own ids. +- **Ten stores are scanned; four families resolve.** The `record_stores` config names ten roots so every record's prose is read, but only `adr`, `itd`, `iss` and `spc` are the cited-id grammar. An `rdi`, `dsp`, `rdg`, `adm`, `srp` or `rfm` id is not a citation to this rule and is checked by nothing here — those stores are in the list for the prose their files carry, not for their own ids. The committed baseline `.abcd/prose-citations-baseline.json` carries the ids that predate the rule, one entry per id with a class and a note, and it ratchets down: an entry whose id resolves or that nothing cites any more is reported as spent (`prose_citation_baseline_stale`, `info`). An entry is a GLOBAL licence for its id, so a mention that can carry a line marker takes the marker instead. From a2f5ecbe8cd56452e3400ef497e2e3f1446fe91b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:00:00 +0100 Subject: [PATCH 35/78] =?UTF-8?q?chore:=20resolve=20iss-2609252159470046?= =?UTF-8?q?=20=E2=80=94=20lint=20chapter=20store=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252159470046 Assisted-by: Claude:claude-opus-5-5 --- ...-chapter-s-section-1-1-says-prose-citation-resolves.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252159470046-the-lint-chapter-s-section-1-1-says-prose-citation-resolves.md (59%) diff --git a/.abcd/work/issues/open/iss-2609252159470046-the-lint-chapter-s-section-1-1-says-prose-citation-resolves.md b/.abcd/work/issues/resolved/iss-2609252159470046-the-lint-chapter-s-section-1-1-says-prose-citation-resolves.md similarity index 59% rename from .abcd/work/issues/open/iss-2609252159470046-the-lint-chapter-s-section-1-1-says-prose-citation-resolves.md rename to .abcd/work/issues/resolved/iss-2609252159470046-the-lint-chapter-s-section-1-1-says-prose-citation-resolves.md index 4011c9323..2910f1b8a 100644 --- a/.abcd/work/issues/open/iss-2609252159470046-the-lint-chapter-s-section-1-1-says-prose-citation-resolves.md +++ b/.abcd/work/issues/resolved/iss-2609252159470046-the-lint-chapter-s-section-1-1-says-prose-citation-resolves.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "The chapter names ten stores and lists rfm among the non-citation families." +impact: internal +resolved_by: + commit: "49b5400f0c3579eca929aaf7f2015ee2600a2290" --- The lint chapter's section 1.1 says prose_citation_resolves scans nine record stores and lists rdi, dsp, rdg, adm and srp as the non-citation stores, while .abcd/record-lint.json names ten under that rule: the reframe store rfm was added to the config and not to the sentence. + +## Grounds + +- pursued: the chapter's count and list agree with prose_citation_resolves' record_stores in .abcd/record-lint.json; a store added to that rule without the sentence moving would show it wrong again From 228d4d329d1a9e5cde2eb95109f01f37cfc7acc0 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:00:16 +0100 Subject: [PATCH 36/78] chore: ship itd-2609020625405170 by closing spc-2609020626042471 The principles family is a declared record store with four typed claim keys and four lint rules over them, a principle travels to a reading as its statement with its keys and citations withheld, and disembark principles carries the keys under payload schema version 2. The spec closes with impact additive and the intent moves to shipped; its fidelity review is owed (rcp-df264ca3973f). Delivers: itd-2609020625405170 Assisted-by: Claude:claude-opus-5-5 --- ...e-carries-typed-claims-its-reference-its-compariso.md | 9 +++++---- ...e-carries-typed-claims-its-reference-its-compariso.md | 4 ++-- ...ondition-is-dispositioned-from-a-reading-run-keyed.md | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) rename .abcd/development/intents/{planned => shipped}/itd-2609020625405170-a-principle-carries-typed-claims-its-reference-its-compariso.md (82%) rename .abcd/development/specs/{open => closed}/spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md (99%) diff --git a/.abcd/development/intents/planned/itd-2609020625405170-a-principle-carries-typed-claims-its-reference-its-compariso.md b/.abcd/development/intents/shipped/itd-2609020625405170-a-principle-carries-typed-claims-its-reference-its-compariso.md similarity index 82% rename from .abcd/development/intents/planned/itd-2609020625405170-a-principle-carries-typed-claims-its-reference-its-compariso.md rename to .abcd/development/intents/shipped/itd-2609020625405170-a-principle-carries-typed-claims-its-reference-its-compariso.md index 045b79501..1f4a18b61 100644 --- a/.abcd/development/intents/planned/itd-2609020625405170-a-principle-carries-typed-claims-its-reference-its-compariso.md +++ b/.abcd/development/intents/shipped/itd-2609020625405170-a-principle-carries-typed-claims-its-reference-its-compariso.md @@ -14,7 +14,7 @@ production_mode: dictated-and-formatted # A principle carries typed claims, its reference, its comparison and its evidence, its statement is readable cold, and it inherits only what held -Typed links: `builds_on` [itd-181](../shipped/itd-181-a-shipped-intent-s-scope-conditions-are-dispositioned-by-the.md) (scope-condition disposition), [itd-177](../shipped/itd-177-an-intent-s-claims-are-typed-and-its-scope-conditions-keep-t.md) (claim typing), [itd-190](../disciplines/itd-190-the-claim-recording-gradient-an-intent-s-three-claim-kinds-c.md) (the claim recording gradient), [itd-183](../shipped/itd-183-the-cold-reading-sees-exactly-what-the-assembler-passes-posi.md) (field projection); `refines` [itd-181](../shipped/itd-181-a-shipped-intent-s-scope-conditions-are-dispositioned-by-the.md) (the first consumer of a condition's disposition). +Typed links: `builds_on` [itd-181](itd-181-a-shipped-intent-s-scope-conditions-are-dispositioned-by-the.md) (scope-condition disposition), [itd-177](itd-177-an-intent-s-claims-are-typed-and-its-scope-conditions-keep-t.md) (claim typing), [itd-190](../disciplines/itd-190-the-claim-recording-gradient-an-intent-s-three-claim-kinds-c.md) (the claim recording gradient), [itd-183](itd-183-the-cold-reading-sees-exactly-what-the-assembler-passes-posi.md) (field projection); `refines` [itd-181](itd-181-a-shipped-intent-s-scope-conditions-are-dispositioned-by-the.md) (the first consumer of a condition's disposition). ## Press Release @@ -26,7 +26,7 @@ Typed links: `builds_on` [itd-181](../shipped/itd-181-a-shipped-intent-s-scope-c The cold-reading design schedules the knowledge-record extension for Iteration 2: claim typing, reference entity, comparison and evidence on `principles/` entries. It names `principles/` as a hard case: in Iteration 2 the knowledge record is a read object, so principle statements sit on the cold side, while their citations back to the ADRs they were distilled from are derivational and excluded, which requires a projection rule rather than a path rule. Today the family is denied to the assembler structurally by the `.abcd` segment and appears on neither the include table nor the declared exclusions, so the manifest is silent about it. -[itd-181](../shipped/itd-181-a-shipped-intent-s-scope-conditions-are-dispositioned-by-the.md) shipped the scope-condition disposition so that later work inherits only what held, and its fidelity verdict found that nothing consumes a disposition: a falsified condition blocks nothing. The consumer the design has in mind is the knowledge record. A principle is what a project carries forward, and a principle resting on an assumption that delivery falsified is exactly the inheritance the disposition exists to prevent. +[itd-181](itd-181-a-shipped-intent-s-scope-conditions-are-dispositioned-by-the.md) shipped the scope-condition disposition so that later work inherits only what held, and its fidelity verdict found that nothing consumes a disposition: a falsified condition blocks nothing. The consumer the design has in mind is the knowledge record. A principle is what a project carries forward, and a principle resting on an assumption that delivery falsified is exactly the inheritance the disposition exists to prevent. ## Decisions flagged for the maintainer @@ -73,7 +73,7 @@ We expect typed evidence on principles to make the knowledge record checkable be ## Prior Art -- [itd-181](../shipped/itd-181-a-shipped-intent-s-scope-conditions-are-dispositioned-by-the.md) (scope-condition disposition), [itd-177](../shipped/itd-177-an-intent-s-claims-are-typed-and-its-scope-conditions-keep-t.md) (claim typing), [itd-190](../disciplines/itd-190-the-claim-recording-gradient-an-intent-s-three-claim-kinds-c.md) (the gradient), [itd-183](../shipped/itd-183-the-cold-reading-sees-exactly-what-the-assembler-passes-posi.md) (field projection), the `disembark` family. +- [itd-181](itd-181-a-shipped-intent-s-scope-conditions-are-dispositioned-by-the.md) (scope-condition disposition), [itd-177](itd-177-an-intent-s-claims-are-typed-and-its-scope-conditions-keep-t.md) (claim typing), [itd-190](../disciplines/itd-190-the-claim-recording-gradient-an-intent-s-three-claim-kinds-c.md) (the gradient), [itd-183](itd-183-the-cold-reading-sees-exactly-what-the-assembler-passes-posi.md) (field projection), the `disembark` family. - The cold-reading rulings of 2026-08-28 in the decision log. ## Open Questions @@ -82,7 +82,8 @@ None. The flagged decisions are adopted as adr-2609021016270132. ## Audit Notes -_Empty. Populated by intent-auditor when intent moves to shipped/._ +<!-- abcd-review: OWED receipt=rcp-df264ca3973f --> +Fidelity review OWED (receipt rcp-df264ca3973f). ## Grounds diff --git a/.abcd/development/specs/open/spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md b/.abcd/development/specs/closed/spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md similarity index 99% rename from .abcd/development/specs/open/spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md rename to .abcd/development/specs/closed/spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md index 01d3291e9..0c7a7e77e 100644 --- a/.abcd/development/specs/open/spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md +++ b/.abcd/development/specs/closed/spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md @@ -10,7 +10,7 @@ production_mode: dictated-and-formatted ## Summary spc-2609020626042471 delivers -[itd-2609020625405170](../../intents/planned/itd-2609020625405170-a-principle-carries-typed-claims-its-reference-its-compariso.md). +[itd-2609020625405170](../../intents/shipped/itd-2609020625405170-a-principle-carries-typed-claims-its-reference-its-compariso.md). An entry under `.abcd/development/principles/` may declare four typed keys in a frontmatter block: `claim_type`, `reference`, `comparison` and `evidence`. The record lint reports an entry carrying none of them as untyped at warn, refuses @@ -39,7 +39,7 @@ No existing entry is renamed, typed or moved. This spec lands last of the Iteration 2 set. It consumes the condition reader the sibling spec -[spc-2609020626046252](../closed/spc-2609020626046252-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md) +[spc-2609020626046252](spc-2609020626046252-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md) introduces, and it takes the include table, the exclusion floor and the eval tables as the earlier specs leave them. diff --git a/.abcd/development/specs/closed/spc-2609020626046252-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md b/.abcd/development/specs/closed/spc-2609020626046252-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md index 4c8bbdabc..24bfd0ed3 100644 --- a/.abcd/development/specs/closed/spc-2609020626046252-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md +++ b/.abcd/development/specs/closed/spc-2609020626046252-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md @@ -32,7 +32,7 @@ The vocabulary the two writers share moves into a leaf package, `internal/core/condition`, so that one enum, one marker grammar and one block reader serve the verdict ingest, this verb, the readiness gate, and the record lint that the sibling spec -[spc-2609020626042471](../open/spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md) +[spc-2609020626042471](spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md) builds over the same dispositions. The reading-item locator moves into a second leaf, `internal/core/readingitem`, which the later Iteration 2 specs share. From 83fd55ea35bdc2a85951e854fce278cffc0a765d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:43:34 +0100 Subject: [PATCH 37/78] chore: capture the reframe review's four findings The lane review of the reframe record returned SHIP with three low findings and one observation that the run drains rather than files: the whole write's date-ordered history walk, the no-Construal refusal naming the wrong reason, the comparative coverage rows' undeclared gap, and the path-only exclusion of a reframe-shaped file planted elsewhere. Refs: iss-2609252243284279 Refs: iss-2609252243286219 Refs: iss-2609252243299568 Refs: iss-2609252243290965 Assisted-by: Claude:claude-opus-5-5 --- ...me-s-whole-write-picks-the-previous-distinct.md | 14 ++++++++++++++ ...frame-s-whole-write-refuses-with-the-frame-s.md | 14 ++++++++++++++ ...exclusion-of-the-reframe-record-is-by-path-a.md | 14 ++++++++++++++ ...omparative-coverage-rows-for-the-reframe-and.md | 14 ++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609252243284279-capture-reframe-s-whole-write-picks-the-previous-distinct.md create mode 100644 .abcd/work/issues/open/iss-2609252243286219-capture-reframe-s-whole-write-refuses-with-the-frame-s.md create mode 100644 .abcd/work/issues/open/iss-2609252243290965-cold-reading-exclusion-of-the-reframe-record-is-by-path-a.md create mode 100644 .abcd/work/issues/open/iss-2609252243299568-the-four-comparative-coverage-rows-for-the-reframe-and.md diff --git a/.abcd/work/issues/open/iss-2609252243284279-capture-reframe-s-whole-write-picks-the-previous-distinct.md b/.abcd/work/issues/open/iss-2609252243284279-capture-reframe-s-whole-write-picks-the-previous-distinct.md new file mode 100644 index 000000000..a927467df --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252243284279-capture-reframe-s-whole-write-picks-the-previous-distinct.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252243284279" +slug: "capture-reframe-s-whole-write-picks-the-previous-distinct" +severity: "minor" +category: "bug" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/capture/reframe.go" +--- + +capture reframe's whole write picks the previous distinct frame state in git log date order, so across a --no-ff merge where both parents moved the frame the before state is whichever parent's commit is newer, and changed depends on timestamps rather than topology: a record can under-report the surfaces the merged rewrite moved diff --git a/.abcd/work/issues/open/iss-2609252243286219-capture-reframe-s-whole-write-refuses-with-the-frame-s.md b/.abcd/work/issues/open/iss-2609252243286219-capture-reframe-s-whole-write-refuses-with-the-frame-s.md new file mode 100644 index 000000000..e34e6b843 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252243286219-capture-reframe-s-whole-write-refuses-with-the-frame-s.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252243286219" +slug: "capture-reframe-s-whole-write-refuses-with-the-frame-s" +severity: "minor" +category: "ux" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/capture/reframe.go" +--- + +capture reframe's whole write refuses with 'the frame's previous state cannot be fingerprinted: carries no H2 section titled Construal' when no distinct prior state lies within the history that carries a Construal section, instead of naming the true reason: no prior distinct state within the fingerprintable history, and how far back that history reaches diff --git a/.abcd/work/issues/open/iss-2609252243290965-cold-reading-exclusion-of-the-reframe-record-is-by-path-a.md b/.abcd/work/issues/open/iss-2609252243290965-cold-reading-exclusion-of-the-reframe-record-is-by-path-a.md new file mode 100644 index 000000000..580983322 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252243290965-cold-reading-exclusion-of-the-reframe-record-is-by-path-a.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252243290965" +slug: "cold-reading-exclusion-of-the-reframe-record-is-by-path-a" +severity: "minor" +category: "security" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lint" +--- + +Cold-reading exclusion of the reframe record is by path: a reframe-shaped file (an rfm-N name or reframe frontmatter) planted outside .abcd/work/issues/reframes/, e.g. as a brief chapter, reaches every cold reading with its grounds and body, and no gate names it, where the open/ look-alike is flagged as a mis-named issue diff --git a/.abcd/work/issues/open/iss-2609252243299568-the-four-comparative-coverage-rows-for-the-reframe-and.md b/.abcd/work/issues/open/iss-2609252243299568-the-four-comparative-coverage-rows-for-the-reframe-and.md new file mode 100644 index 000000000..3853f49ea --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252243299568-the-four-comparative-coverage-rows-for-the-reframe-and.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252243299568" +slug: "the-four-comparative-coverage-rows-for-the-reframe-and" +severity: "minor" +category: "drift" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "evals/coldreading_coverage_test.go" +--- + +The four comparative coverage rows for the reframe and derived families state a two-clause Rule (never reach the bundle AND the manifest says so) while only the manifest clause is falsified; the unfalsified leak half is disclosed in a code comment, not in the row's Gap field the matrix header names as the place for an unfalsifiable claim From b534f1c6a77f30c1692f27fc7d12f5348cf19333 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:47:02 +0100 Subject: [PATCH 38/78] fix(capture): the reframe whole write walks first parents, not dates The whole write took the previous distinct frame state as the first differing triple in `git log` date order, so across a --no-ff merge where both lines moved the frame the before state was whichever parent's commit was newer, and `changed` named only the other line's surfaces. The outcome depended on timestamps, not topology. The choice is --first-parent, not --topo-order. The spec's "state before the rewrite" is the state the rewrite replaced on the line HEAD stands on, and it says the merge strategy does not decide the outcome. A squash or a rebase of the rewrite branch onto a main that moved another surface records that other surface as unchanged; walking first parents records the --no-ff merge the same way, in either timestamp order. --topo-order would still stop at one parent's state, just a fixed one, and a merge-base reading would name main's own move as part of the rewrite, which no squash would. The completion keeps walking every line: it seeks one known triple, which a merged branch may hold alone. The command page and the capture surface chapter say the walk follows first parents. Refs: iss-2609252243284279 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 5 +- commands/capture.md | 6 +- internal/core/capture/reframe.go | 29 +++++++-- internal/core/capture/reframe_test.go | 60 +++++++++++++++++++ 4 files changed, 92 insertions(+), 8 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index 25eac0b94..b08098c2c 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -208,7 +208,10 @@ reading item, a disposition or a surprise), the SHA-256 fingerprint of each surface before and after, which surfaces changed, and the ground, and no text of any surface. The verb reads the surfaces at `HEAD`, in the working tree and along their history, so the operator supplies no hash. Written after the -rewrite's commit it is one write; written before it, a first half records the +rewrite's commit it is one write, against the previous distinct state along +first parents, so a rewrite a merge brought in is recorded as a squash of the +same branch would record it, whatever the commits' timestamps; written before +it, a first half records the before fingerprints and a second write finishes it once the rewrite is committed, walking back across as many commits as the rewrite took, merges included. Every render names the half it wrote. The occasion diff --git a/commands/capture.md b/commands/capture.md index 3e5c29c0a..a3f7e12a1 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -525,8 +525,10 @@ A reframe is written in one of three halves, and every render names which: - **Whole**, after the rewrite is committed (no flag). All three surfaces in the working tree must match `HEAD`, or the verb refuses naming the one that does - not. It walks the surfaces' history to the previous distinct committed state - and writes both halves at once. + not. It walks the surfaces' history along first parents to the previous + distinct committed state and writes both halves at once, so a rewrite a merge + brought in is recorded against the state the merge's first parent held, as a + squash of the same branch would be, whatever the commits' timestamps. - **Open**, before the rewrite is committed (`--open`). The before fingerprints are `HEAD`'s and the after half is absent; the render names the completion. Only one record may be open at a time. diff --git a/internal/core/capture/reframe.go b/internal/core/capture/reframe.go index 59008a3a3..540badfd6 100644 --- a/internal/core/capture/reframe.go +++ b/internal/core/capture/reframe.go @@ -389,9 +389,28 @@ type frameWalk struct { cache blobCache } -// frameHistory reads the bounded list of commits touching the frame. -func frameHistory(repoRoot string, cache blobCache) (*frameWalk, error) { - args := append([]string{"log", "--format=%H", "-n", fmt.Sprint(frameHistoryBound), "HEAD", "--"}, frameSurfacePaths()...) +// The two shapes a walk reads the history in. +const ( + // walkMainline follows first parents only, so across a merge the state + // before is the one the line HEAD stands on held, whichever line holds the + // newer commits: a rewrite brought in by a --no-ff merge is recorded as a + // squash or a rebase of the same branch would record it, and the outcome is + // fixed by topology, never by timestamps. The whole write reads this. + walkMainline = true + // walkEveryLine reads every line of history, so a before triple held only + // on a merged branch is still found. The completion reads this: it looks + // for one known triple, and which line holds it does not matter. + walkEveryLine = false +) + +// frameHistory reads the bounded list of commits touching the frame, along +// first parents only when mainline is set. +func frameHistory(repoRoot string, cache blobCache, mainline bool) (*frameWalk, error) { + args := []string{"log", "--format=%H", "-n", fmt.Sprint(frameHistoryBound)} + if mainline { + args = append(args, "--first-parent") + } + args = append(append(args, "HEAD", "--"), frameSurfacePaths()...) out, err := gitutil.RunCapped(repoRoot, maxStatusBytes, args...) if err != nil { return nil, fmt.Errorf("cannot read the frame's history: %w", err) @@ -492,7 +511,7 @@ func Reframe(req ReframeRequest) (ReframeResult, error) { if err := requireWorkingTreeAtHead(repoRoot, head, "commit the rewrite, or record the first half with --open"); err != nil { return ReframeResult{}, err } - walk, err := frameHistory(repoRoot, cache) + walk, err := frameHistory(repoRoot, cache, walkMainline) if err != nil { return ReframeResult{}, err } @@ -610,7 +629,7 @@ func completeReframe(repoRoot, issuesRoot string, req ReframeRequest) (ReframeRe return ReframeResult{}, fmt.Errorf("%w: the frame at HEAD is still the state the record opened against; nothing was rewritten, so commit the rewrite before completing %s (nothing written)", ErrInvariantViolation, id) } - walk, err := frameHistory(repoRoot, cache) + walk, err := frameHistory(repoRoot, cache, walkEveryLine) if err != nil { return ReframeResult{}, err } diff --git a/internal/core/capture/reframe_test.go b/internal/core/capture/reframe_test.go index a81577145..2abba9308 100644 --- a/internal/core/capture/reframe_test.go +++ b/internal/core/capture/reframe_test.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "errors" "os" + "os/exec" "path/filepath" "slices" "strings" @@ -340,6 +341,65 @@ func TestReframeRefusesAFrameWithNoPriorState(t *testing.T) { } } +// commitDated stages everything and commits it with both dates pinned, so a +// fixture can order commits on two lines of history by timestamp. +func commitDated(t *testing.T, r *gittest.Repo, msg, date string, extra ...string) { + t.Helper() + r.Git("add", "-A") + args := append([]string{"-C", r.Root(), "-c", "user.email=fixture@example.invalid", "-c", "user.name=Fixture", + "-c", "commit.gpgsign=false"}, extra...) + if len(extra) == 0 { + args = append(args, "commit", "-q", "-m", msg) + } + cmd := exec.Command("git", args...) + cmd.Env = append(r.Env(), "GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +// ac-1 across a --no-ff merge: the rewrite is what the merge brought to the +// line HEAD stands on, so the previous distinct state is its first parent's, +// whichever line of history holds the newer commits. The branch moves the +// construal and a glossary term in two commits while main moves the scope; +// in both timestamp orders `changed` names both surfaces the merge brought, +// exactly as a squash of the same branch would. +func TestReframeWholeWriteAcrossAMergeIsTopological(t *testing.T) { + for _, tc := range []struct { + name string + branch1, branch2 string + mainline, merge string + }{ + {"the branch's commits are newer", "2030-01-02T00:00:00Z", "2030-01-03T00:00:00Z", "2030-01-01T00:00:00Z", "2030-01-04T00:00:00Z"}, + {"main's commit is newer", "2030-01-01T00:00:00Z", "2030-01-02T00:00:00Z", "2030-01-03T00:00:00Z", "2030-01-04T00:00:00Z"}, + } { + t.Run(tc.name, func(t *testing.T) { + r := reframeFixture(t) + r.Git("checkout", "-q", "-b", "rewrite") + r.Write(fxFraming, framingDoc("The rewrite on its own branch.")) + commitDated(t, r, "rewrite the construal", tc.branch1) + r.Write(fxGlossary+"/core/term.md", "# Term\n\nThe branch's sharper term.\n") + commitDated(t, r, "rewrite a term", tc.branch2) + r.Git("checkout", "-q", "main") + r.Write(fxScope, scopeDoc("Main moved the scope meanwhile.")) + commitDated(t, r, "move the scope on main", tc.mainline) + mainline := frameAt(t, r) + commitDated(t, r, "", tc.merge, "merge", "-q", "--no-ff", "-m", "merge the rewrite", "rewrite") + + res, err := Reframe(reframeReq(r, fxItem)) + if err != nil { + t.Fatalf("Reframe: %v", err) + } + if !slices.Equal(res.Changed, []string{"construal", "glossary"}) { + t.Fatalf("changed = %v, want [construal glossary]: the surfaces the merge brought, not a date-ordered neighbour's", res.Changed) + } + if res.Before != mainline || res.After != frameAt(t, r) || res.Commits != 1 { + t.Fatalf("result = %+v\nwant before = the first parent's state %+v across 1 commit", res, mainline) + } + }) + } +} + func TestReframeRefusesUncommittedChangesWithoutOpen(t *testing.T) { r := reframeFixture(t) rewriteConstrual(r, "A committed rewrite.") From 44ad3c4300532752100bc0a1b166dc988dc2ab46 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:47:50 +0100 Subject: [PATCH 39/78] fix(capture): a reframe with no fingerprintable prior state says so A whole write whose older frame states carry no `## Construal` section refused with "the frame's previous state cannot be fingerprinted", naming a fingerprint failure when the true reason is that no distinct prior state lies within the history that can be fingerprinted. In the real history that history is the twelve commits since the section was introduced, so the effective bound is shorter than 64 until it grows. A state whose content has no fingerprint (a surface absent, or a framing chapter without exactly one Construal section) is now marked as such, and the whole write treats it as the end of the fingerprintable history: the refusal says the frame matches no prior committed state within it, how many commits touching the frame it reaches back, to which commit, and why the state before that cannot be compared. A failure to read git keeps its own refusal. Refs: iss-2609252243286219 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 3 +- commands/capture.md | 4 +- internal/core/capture/reframe.go | 22 +++++++++- internal/core/capture/reframe_test.go | 41 +++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index b08098c2c..5483d06a2 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -219,7 +219,8 @@ is checked in one respect: the commit that added it precedes the rewrite. Refused with nothing written: an occasion outside the three families or naming no record, one not committed or committed after the rewrite, a degenerate ground, uncommitted surface changes outside a first half, a frame with no distinct -prior state, a second open record, a completion in which nothing moved, and a +prior state within its fingerprintable history (named with how far back that +history reaches), a second open record, a completion in which nothing moved, and a before state the history no longer holds within 64 commits touching the frame. **Resolving** marks an issue resolved and moves it to diff --git a/commands/capture.md b/commands/capture.md index a3f7e12a1..cc15fc945 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -547,7 +547,9 @@ that the occasion caused the rewrite. Report the `id`, `half`, `changed`, Everything the verb refuses writes nothing: an occasion outside the three families or naming no record, an occasion not yet committed or committed after the rewrite, a ground below the floor, uncommitted changes to a surface without -`--open`, a frame with no distinct prior committed state, a second open record, +`--open`, a frame with no distinct prior committed state (the refusal says how +far back the fingerprintable history reaches: a state whose framing chapter has +no `Construal` section ends it), a second open record, a completion in which no surface moved, and a completion whose before state the surfaces' history no longer holds within 64 commits touching the frame (both states named). The family is warm: no cold reading receives it, and every diff --git a/internal/core/capture/reframe.go b/internal/core/capture/reframe.go index 540badfd6..2d6d6156b 100644 --- a/internal/core/capture/reframe.go +++ b/internal/core/capture/reframe.go @@ -22,6 +22,7 @@ package capture import ( "crypto/sha256" "encoding/hex" + "errors" "fmt" "os" "path" @@ -261,11 +262,20 @@ func frameSurfacePaths() []string { return out } +// unfingerprintableState marks a frame state whose content has no fingerprint: +// a surface absent, or a framing chapter without exactly one Construal section. +// A walk treats it as the end of the fingerprintable history, which a failure +// to read git is not. +type unfingerprintableState struct{ err error } + +func (e unfingerprintableState) Error() string { return e.err.Error() } +func (e unfingerprintableState) Unwrap() error { return e.err } + // fingerprintFrame composes the triple from the three surfaces' content. func fingerprintFrame(framing, scope string, glossary map[string][]byte) (Frame, error) { c, err := ConstrualFingerprint(framing) if err != nil { - return Frame{}, err + return Frame{}, unfingerprintableState{err} } return Frame{Construal: c, Glossary: GlossaryFingerprint(glossary), Scope: ScopeFingerprint(scope)}, nil } @@ -308,7 +318,7 @@ func frameAtCommit(repoRoot, rev string, cache blobCache) (Frame, error) { chapter := func(s FrameSurface) (string, error) { oid, ok := blobs[s.Path] if !ok { - return "", fmt.Errorf("the %s surface %s is absent at %s", s.Name, s.Path, shortRev(rev)) + return "", unfingerprintableState{fmt.Errorf("the %s surface %s is absent at %s", s.Name, s.Path, shortRev(rev))} } return read(oid) } @@ -518,6 +528,14 @@ func Reframe(req ReframeRequest) (ReframeResult, error) { i := 0 for ; i < len(walk.commits); i++ { f, err := walk.at(i) + var state unfingerprintableState + if errors.As(err, &state) && i > 0 { + // Every state from HEAD back to here equals HEAD's, and the + // one before cannot be compared: the fingerprintable history + // ends here, holding no distinct state. + return ReframeResult{}, fmt.Errorf("%w: the frame at HEAD matches no prior committed state within its fingerprintable history, so there is no reframe to record: that history reaches back %d commit(s) touching the frame, to %s, and the state before it, at %s, cannot be fingerprinted (%v) (nothing written)", + ErrInvariantViolation, i, shortRev(walk.commits[i-1]), shortRev(walk.commits[i]), state) + } if err != nil { return ReframeResult{}, fmt.Errorf("%w: the frame's previous state cannot be fingerprinted: %v; there is no prior committed state to record against (nothing written)", ErrInvariantViolation, err) diff --git a/internal/core/capture/reframe_test.go b/internal/core/capture/reframe_test.go index 2abba9308..59325986e 100644 --- a/internal/core/capture/reframe_test.go +++ b/internal/core/capture/reframe_test.go @@ -341,6 +341,47 @@ func TestReframeRefusesAFrameWithNoPriorState(t *testing.T) { } } +// A history whose older states carry no Construal section has a fingerprintable +// history that stops where the section begins. A frame with no distinct state +// inside it is refused for that reason — no prior state within the history that +// can be fingerprinted, and how far back that history reaches — not as though +// the fingerprint itself had failed. +func TestReframeRefusesNamingHowFarTheFingerprintableHistoryReaches(t *testing.T) { + r := gittest.NewRepo(t) + r.Write(fxFraming, "# Framing\n\nNo construal section yet.\n") + r.Write(fxScope, scopeDoc("The scope.")) + r.Write(fxGlossary+"/core/term.md", "# Term\n\nA term.\n") + r.Write(".abcd/work/issues/readings/rdg-1/"+fxItem+".md", "---\nid: rdi-11\n---\n") + r.Commit("the frame before it had a construal section") + unsectioned := r.Git("rev-parse", "--short=12", "HEAD") + r.Write(fxFraming, framingDoc("The first construal.")) + r.Commit("introduce the construal section") + sectioned := r.Git("rev-parse", "--short=12", "HEAD") + + before := ledgerDigest(t, ledgerOf(r)) + _, err := Reframe(reframeReq(r, fxItem)) + if err == nil { + t.Fatal("a frame with no distinct fingerprintable prior state was recorded") + } + msg := err.Error() + for _, want := range []string{ + "matches no prior committed state within its fingerprintable history", + "reaches back 1 commit(s) touching the frame, to " + sectioned, + "the state before it, at " + unsectioned + ", cannot be fingerprinted", + `no H2 section titled "Construal"`, + } { + if !strings.Contains(msg, want) { + t.Errorf("refusal lacks %q:\n%s", want, msg) + } + } + if strings.Contains(msg, "the frame's previous state cannot be fingerprinted") { + t.Errorf("refusal names a fingerprint failure rather than the missing prior state:\n%s", msg) + } + if ledgerDigest(t, ledgerOf(r)) != before { + t.Fatal("a refused reframe changed the ledger") + } +} + // commitDated stages everything and commits it with both dates pinned, so a // fixture can order commits on two lines of history by timestamp. func commitDated(t *testing.T, r *gittest.Repo, msg, date string, extra ...string) { From 3d06143ebef1dfd3091e5c308c671a9c11f88cbd Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:49:30 +0100 Subject: [PATCH 40/78] test(evals): the comparative derived rows declare their leak half a gap The four comparative rows for dispositions, admissions, surprises and reframe records each stated two rules in one Rule ("never reach the comparative reading, and its manifest says so") while only the manifest half is falsified. The leak half was disclosed in a code comment, not in Gap, which the matrix header names as the place for a claim no mutation can falsify, and a row cannot be both caught and a gap. Each row is split in two: the leak half, whose mutation (an include row for the family at comparative) admits nothing a plant could die at, declared a gap with that reason; and the manifest half, caught by path as watched. The matrix pins move deliberately: 80 to 84 rows, 7 to 11 declared gaps. Watched red on each pin before it was moved. Refs: iss-2609252243299568 Assisted-by: Claude:claude-opus-5-5 --- evals/coldreading_coverage_test.go | 71 +++++++++++++++++++++--------- evals/coldreading_oracle_test.go | 2 +- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/evals/coldreading_coverage_test.go b/evals/coldreading_coverage_test.go index c754996fa..b74df2059 100644 --- a/evals/coldreading_coverage_test.go +++ b/evals/coldreading_coverage_test.go @@ -681,36 +681,63 @@ var coverage = []coverageRow{ Caught: caughtLeak, Classes: []string{"EXHAUST"}, }, - // The four derived family rows below are caught by PATH: an include row for - // a family at comparative leaks nothing on this corpus, because the - // comparative preset selects only the discipline kind and the candidate set, - // so no plant can die there. What the derived row's removal does move is the - // manifest, and the family-absence oracle's comparative rows name the - // assertion that went missing (iss-2609251812216267). - { - Rule: "dispositions never reach the comparative reading, and its manifest says so", - Falsifier: "drop the dispositions directory from issueschema.LedgerDirs, so the derived row disappears", + // The four derived families at comparative are two rules each, and only one + // of them is falsifiable here. The manifest half is caught by PATH: with the + // derived row gone the comparative manifest stops asserting the family's + // exclusion, and the family-absence oracle's comparative row names it + // (iss-2609251812216267). The leak half is a declared gap: an include row + // for the family at comparative leaks nothing on this corpus, because the + // comparative preset selects only the discipline kind and the candidate + // set, so no plant can die there (iss-2609252243299568). + { + Rule: "dispositions never reach the comparative reading", + Falsifier: "add an include row for the dispositions directory at comparative", + Gap: "unfalsifiable on this corpus: the comparative preset selects only the " + + "discipline kind and the candidate set, so an include row for the family at " + + "comparative admits nothing a plant could die at. The manifest half of the " + + "rule is the row below, and it is caught", + }, + { + Rule: "the comparative manifest asserts that dispositions are excluded", + Falsifier: "drop the dispositions directory from issueschema.LedgerDirs, so the derived dispositions row disappears", Caught: caughtFamily, }, { - Rule: "admissions never reach the comparative reading, and its manifest says so", - Falsifier: "drop the admissions directory from issueschema.LedgerDirs, so the derived row disappears", + Rule: "admissions never reach the comparative reading", + Falsifier: "add an include row for the admissions directory at comparative", + Gap: "unfalsifiable on this corpus: the comparative preset selects only the " + + "discipline kind and the candidate set, so an include row for the family at " + + "comparative admits nothing a plant could die at. The manifest half of the " + + "rule is the row below, and it is caught", + }, + { + Rule: "the comparative manifest asserts that admissions are excluded", + Falsifier: "drop the admissions directory from issueschema.LedgerDirs, so the derived admissions row disappears", Caught: caughtFamily, }, { - Rule: "surprises never reach the comparative reading, and its manifest says so", - Falsifier: "drop the surprises directory from issueschema.LedgerDirs, so the derived row disappears", + Rule: "surprises never reach the comparative reading", + Falsifier: "add an include row for the surprises directory at comparative", + Gap: "unfalsifiable on this corpus: the comparative preset selects only the " + + "discipline kind and the candidate set, so an include row for the family at " + + "comparative admits nothing a plant could die at. The manifest half of the " + + "rule is the row below, and it is caught", + }, + { + Rule: "the comparative manifest asserts that surprises are excluded", + Falsifier: "drop the surprises directory from issueschema.LedgerDirs, so the derived surprises row disappears", Caught: caughtFamily, }, { - // Caught by path, not by plant, and watched: with the derived row gone the - // comparative manifest stops asserting the family's exclusion, and the - // family-absence oracle's comparative row for reframes names it. An - // include row added at comparative leaks nothing on this corpus, because - // the comparative preset selects only the discipline kind and the - // candidate set, so the leak half of this rule has no plant that reaches - // it (spc-2609020626048705). - Rule: "reframe records never reach the comparative reading, and its manifest says so (spc-2609020626048705)", + Rule: "reframe records never reach the comparative reading (spc-2609020626048705)", + Falsifier: "add an include row for the reframes directory at comparative", + Gap: "unfalsifiable on this corpus: the comparative preset selects only the " + + "discipline kind and the candidate set, so an include row for the family at " + + "comparative admits nothing a plant could die at. The manifest half of the " + + "rule is the row below, and it is caught", + }, + { + Rule: "the comparative manifest asserts that reframe records are excluded (spc-2609020626048705)", Falsifier: "drop ReframesDir from issueschema.LedgerDirs, so the derived reframes row disappears", Caught: caughtFamily, }, @@ -832,7 +859,7 @@ func TestEveryAssemblerRuleHasAFalsifier(t *testing.T) { // The gap count is declared, so a row silently becoming unfalsifiable — the // exact way this eval would decay — has to be an explicit edit. - const declaredGaps = 7 + const declaredGaps = 11 if gaps != declaredGaps { t.Errorf("the matrix declares %d unfalsifiable row(s) and holds %d; a rule sliding "+ "into or out of unfalsifiable coverage is the change this eval most needs said "+ diff --git a/evals/coldreading_oracle_test.go b/evals/coldreading_oracle_test.go index d67a89c89..80fad8822 100644 --- a/evals/coldreading_oracle_test.go +++ b/evals/coldreading_oracle_test.go @@ -647,7 +647,7 @@ func requireOracleTables(t *testing.T) { {"excludedHeadings", len(excludedHeadings), 4}, {"excludedFamilies", len(excludedFamilies), 23}, {"admittedRecordPaths", len(admittedRecordPaths), 13}, - {"coverage", len(coverage), 80}, + {"coverage", len(coverage), 84}, } { if tbl.got != tbl.want { t.Fatalf("the %s table holds %d row(s), and this eval is written against %d; "+ From cbb543513e6b32a9a20a384180b9d410bc2272d9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:52:34 +0100 Subject: [PATCH 41/78] feat(lint): cross_store_id_claim names a reframe record outside its store The cold reading excludes the reframe record by its store's path, so a reframe-shaped file planted anywhere else (a brief chapter, say) reached every cold reading with its grounds and body, and no gate named it; the open/ look-alike was caught only because it sits inside the issue store. cross_store_id_claim gains a reframe arm over the same candidate set (tracked markdown outside every configured store): a file whose name is rfm-N, whose frontmatter id is rfm-N, or whose frontmatter carries a reframe's occasioned_by beside a before fingerprint is a blocker naming the store it belongs in and the reading it would reach. It fires on one signal, unlike the id-claim arm, because the hazard is the content travelling rather than a collision with a held id. A file in a nested tree's own reframe store (a fixture repository, denied to every reading by its .abcd segment) and a page that only mentions a reframe are left alone; with no rfm store declared the arm is off. The repository's own record-lint run stays clean, and a scratch copy with a reframe planted as a brief chapter exits 1 naming it. Refs: iss-2609252243290965 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 5 +- internal/core/lint/crossstore.go | 69 +++++++++++++++- internal/core/lint/crossstore_test.go | 80 +++++++++++++++++++ 3 files changed, 151 insertions(+), 3 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index 5483d06a2..a7961bffe 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -221,7 +221,10 @@ no record, one not committed or committed after the rewrite, a degenerate ground, uncommitted surface changes outside a first half, a frame with no distinct prior state within its fingerprintable history (named with how far back that history reaches), a second open record, a completion in which nothing moved, and a -before state the history no longer holds within 64 commits touching the frame. +before state the history no longer holds within 64 commits touching the frame. The +readings keep the record out by its store's path, so record-lint's +`cross_store_id_claim` refuses a reframe-shaped file (an `rfm-N` name, an +`rfm-N` id, or `occasioned_by` beside a before fingerprint) anywhere else. **Resolving** marks an issue resolved and moves it to `resolved/`. Impact is required, and resolving without it is refused with diff --git a/internal/core/lint/crossstore.go b/internal/core/lint/crossstore.go index 6667d0730..b6c23fde0 100644 --- a/internal/core/lint/crossstore.go +++ b/internal/core/lint/crossstore.go @@ -1,7 +1,9 @@ package lint // The cross-store family (cross_store_id_claim): a record id claimed by a -// document that is not in the store that id belongs to. +// document that is not in the store that id belongs to. A second arm, below, +// names a reframe record sitting outside its store, which the cold reading +// would otherwise receive (spc-2609020626048705). // // record_schema reasons across the stores, but only INSIDE them: a file outside // every configured store is not a malformed record to the engine, it is not a @@ -34,12 +36,14 @@ package lint import ( "os" + "path" "path/filepath" "regexp" "sort" "strconv" "strings" + "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" ) @@ -98,7 +102,8 @@ func checkCrossStoreIDClaim(repoRoot string, cfg Config, rc RuleConfig) ([]Findi for _, n := range graph.Nodes { taken[canonRecordID(strings.ToLower(n.ID))] = true } - if len(taken) == 0 { + reframeStore := strings.TrimSuffix(filepath.ToSlash(stores[issueschema.ReframeFamily]), "/") + if len(taken) == 0 && reframeStore == "" { return nil, nil } @@ -134,6 +139,10 @@ func checkCrossStoreIDClaim(repoRoot string, cfg Config, rc RuleConfig) ([]Findi continue } lines := strings.Split(string(content), "\n") + if f, ok := reframeOutsideStore(rel, lines, reframeStore, rc.Severity); ok { + out = append(out, f) + continue + } if f, ok := crossStoreClaim(rel, lines, taken, rc.Severity); ok { out = append(out, f) } @@ -238,6 +247,62 @@ func crossStoreClaim(rel string, lines []string, taken map[string]bool, severity }, true } +// The reframe arm. A reframe record is warm at every reading position, and the +// assembler keeps it out by the PATH of its store (spc-2609020626048705): a +// reframe-shaped file anywhere else is a file like any other to the include +// table, so a copy planted as a brief chapter reaches every cold reading with its +// grounds and its body, and nothing else names it. Unlike the id-claim arm this +// one fires on ONE signal, because the hazard is the content travelling, not a +// collision with a held id: an rfm-N file name, an rfm id in the frontmatter, or +// the reframe's own pair of keys — the occasion and a before fingerprint — which +// no other record carries together. +var ( + reframeFileNameRe = regexp.MustCompile(`(?i)^rfm-\d+(?:[-.]|$)`) + reframeIDRe = regexp.MustCompile(`(?i)^rfm-\d+$`) +) + +// reframeOutsideStore judges one candidate outside every store. A file inside +// a directory ending in the reframe store's own path is a nested tree's store — +// a fixture repository, which every reading denies by its `.abcd` segment — and +// is its own record, not a stray one. +func reframeOutsideStore(rel string, lines []string, store, severity string) (Finding, bool) { + if store == "" || strings.HasSuffix(path.Dir(rel), "/"+store) { + return Finding{}, false + } + fields := frontmatterFields(lines) + var why string + var line int + switch { + case reframeFileNameRe.MatchString(path.Base(rel)): + why, line = "its name is a reframe record's ("+path.Base(rel)+")", 1 + case reframeIDRe.MatchString(fieldValue(fields, "id")): + why, line = "its frontmatter claims the reframe id "+strings.ToLower(fieldValue(fields, "id")), fields["id"].line + default: + occ, hasOcc := fields["occasioned_by"] + if !hasOcc { + return Finding{}, false + } + for _, n := range issueschema.FrameSurfaceNames { + if _, ok := fields[n+"_before"]; ok { + why, line = "its frontmatter carries a reframe record's occasioned_by and "+n+"_before", occ.line + break + } + } + if why == "" { + return Finding{}, false + } + } + if line == 0 { + line = 1 + } + return Finding{ + File: rel, Line: line, RuleID: ruleCrossStoreIDClaim, Severity: severity, + Message: "a reframe record outside its store: " + why + ", but it does not sit in " + store + "/. " + + "A reframe is warm, and the cold reading keeps it out by that store's path alone, so a copy anywhere " + + "else reaches every cold reading with its grounds and body. Move it into the store, or remove it", + }, true +} + // hasDecisionShape reports whether the body DECLARES a record lifecycle status — // a `## Status` section or a `Status:` line whose value is one of the record's // own lifecycle states. Fenced blocks are masked by the shared fenceMask: a page diff --git a/internal/core/lint/crossstore_test.go b/internal/core/lint/crossstore_test.go index 7976de654..cccd5caf4 100644 --- a/internal/core/lint/crossstore_test.go +++ b/internal/core/lint/crossstore_test.go @@ -237,3 +237,83 @@ func TestCrossStoreIDClaimSkipsUntrackedFiles(t *testing.T) { t.Fatalf("expected the tracked probe to fire, got %d: %+v", n, fs) } } + +// reframeStoreCfg arms the cross-store rule with the reframe store declared +// and NO record held anywhere, so an outside-store reframe is judged on its +// own shape rather than against a taken id. +func reframeStoreCfg() Config { + return Config{ + Rules: map[string]RuleConfig{ + ruleCrossStoreIDClaim: {Enabled: true, Severity: severityBlocker}, + ruleRecordSchema: {Enabled: false, RecordStores: map[string]string{ + "iss": ".abcd/work/issues", + "rfm": ".abcd/work/issues/reframes", + }}, + }, + } +} + +// reframeLookAlike is a reframe record in its written shape: warm grounds in +// the frontmatter and a body, exactly what the reading exclusion keeps out by +// the store's PATH. +const reframeLookAlike = "---\nschema_version: 1\nid: rfm-9\noccasioned_by: rdi-11\n" + + "construal_before: " + "0000000000000000000000000000000000000000000000000000000000000000" + "\n" + + "grounds: why the frame moved\n---\n\nThe warm body.\n" + +// A reframe record is excluded from every cold reading by the path of its +// store, so a reframe-shaped file anywhere else reaches a reading with its +// grounds and body. The rule names every one of the three shapes that make a +// file a reframe outside its store — the rfm-N name, an rfm id in the +// frontmatter, and the reframe's own keys — and leaves the store, a nested +// tree's own store, and a page that only mentions a reframe alone. +func TestCrossStoreFlagsAReframeOutsideItsStore(t *testing.T) { + root := t.TempDir() + writeFile(t, root, filepath.Join(".abcd", "work", "issues", "reframes", "rfm-1.md"), reframeLookAlike) + writeFile(t, root, filepath.Join("evals", "testdata", "repo", ".abcd", "work", "issues", "reframes", "rfm-1.md"), reframeLookAlike) + writeFile(t, root, filepath.Join("docs", "reframes.md"), "# Reframes\n\nSee rfm-9 for the shape.\n") + + planted := map[string]string{ + filepath.Join(".abcd", "development", "brief", "01-product", "rfm-9.md"): "# A chapter\n\nNo frontmatter, only the name.\n", + filepath.Join(".abcd", "development", "brief", "01-product", "notes.md"): "---\nid: RFM-3\n---\n\n# Notes\n", + filepath.Join(".abcd", "development", "brief", "01-product", "moved.md"): "---\noccasioned_by: dsp-5\nscope_before: abc\n---\n\n# Moved\n", + filepath.Join(".abcd", "development", "brief", "01-product", "record.md"): reframeLookAlike, + } + for rel, body := range planted { + writeFile(t, root, rel, body) + } + + fs, err := Lint(reframeStoreCfg(), root) + if err != nil { + t.Fatal(err) + } + if n := countRule(fs, ruleCrossStoreIDClaim); n != len(planted) { + t.Fatalf("expected the %d planted reframes and nothing else, got %d: %+v", len(planted), n, fs) + } + for rel := range planted { + found := false + for _, f := range fs { + if f.RuleID == ruleCrossStoreIDClaim && filepath.ToSlash(f.File) == filepath.ToSlash(rel) { + found = true + if f.Severity != severityBlocker || !strings.Contains(f.Message, ".abcd/work/issues/reframes") || + !strings.Contains(f.Message, "cold reading") { + t.Errorf("%s: finding = %+v, want a blocker naming the store and the reading it would reach", rel, f) + } + } + } + if !found { + t.Errorf("%s: no finding; got %+v", rel, fs) + } + } + + // With no reframe store declared, the arm has nothing to weigh a reframe + // against and names nothing. + cfg := reframeStoreCfg() + rs := cfg.Rules[ruleRecordSchema] + rs.RecordStores = map[string]string{"iss": ".abcd/work/issues"} + cfg.Rules[ruleRecordSchema] = rs + if fs, err = Lint(cfg, root); err != nil { + t.Fatal(err) + } else if n := countRule(fs, ruleCrossStoreIDClaim); n != 0 { + t.Fatalf("no reframe store declared, yet %d finding(s): %+v", n, fs) + } +} From c66f37e152e1e29709f5d66ffc78d4a0d19169fc Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:53:11 +0100 Subject: [PATCH 42/78] =?UTF-8?q?chore:=20resolve=20iss-2609252243284279?= =?UTF-8?q?=20=E2=80=94=20the=20reframe=20whole=20write=20walks=20first=20?= =?UTF-8?q?parents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252243284279 Assisted-by: Claude:claude-opus-5-5 --- ...e-reframe-s-whole-write-picks-the-previous-distinct.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252243284279-capture-reframe-s-whole-write-picks-the-previous-distinct.md (56%) diff --git a/.abcd/work/issues/open/iss-2609252243284279-capture-reframe-s-whole-write-picks-the-previous-distinct.md b/.abcd/work/issues/resolved/iss-2609252243284279-capture-reframe-s-whole-write-picks-the-previous-distinct.md similarity index 56% rename from .abcd/work/issues/open/iss-2609252243284279-capture-reframe-s-whole-write-picks-the-previous-distinct.md rename to .abcd/work/issues/resolved/iss-2609252243284279-capture-reframe-s-whole-write-picks-the-previous-distinct.md index a927467df..4ab050b06 100644 --- a/.abcd/work/issues/open/iss-2609252243284279-capture-reframe-s-whole-write-picks-the-previous-distinct.md +++ b/.abcd/work/issues/resolved/iss-2609252243284279-capture-reframe-s-whole-write-picks-the-previous-distinct.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/capture/reframe.go" +resolution: "capture reframe's whole write walks the surfaces' history along first parents, so across a --no-ff merge the before state is the first parent's and changed names what the merge brought, in either timestamp order" +impact: fix +resolved_by: + commit: "b534f1c6" --- capture reframe's whole write picks the previous distinct frame state in git log date order, so across a --no-ff merge where both parents moved the frame the before state is whichever parent's commit is newer, and changed depends on timestamps rather than topology: a record can under-report the surfaces the merged rewrite moved + +## Grounds + +- pursued: a merge where the branch moves two surfaces and main a third records the branch's two in both timestamp orders, as a squash would; a before state or changed that moved with commit dates would show it wrong From 1f314ca57133c75f0d7ec06ed4074c94068475d0 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:53:12 +0100 Subject: [PATCH 43/78] =?UTF-8?q?chore:=20resolve=20iss-2609252243286219?= =?UTF-8?q?=20=E2=80=94=20the=20reframe=20refusal=20names=20the=20fingerpr?= =?UTF-8?q?intable=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252243286219 Assisted-by: Claude:claude-opus-5-5 --- ...ture-reframe-s-whole-write-refuses-with-the-frame-s.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252243286219-capture-reframe-s-whole-write-refuses-with-the-frame-s.md (57%) diff --git a/.abcd/work/issues/open/iss-2609252243286219-capture-reframe-s-whole-write-refuses-with-the-frame-s.md b/.abcd/work/issues/resolved/iss-2609252243286219-capture-reframe-s-whole-write-refuses-with-the-frame-s.md similarity index 57% rename from .abcd/work/issues/open/iss-2609252243286219-capture-reframe-s-whole-write-refuses-with-the-frame-s.md rename to .abcd/work/issues/resolved/iss-2609252243286219-capture-reframe-s-whole-write-refuses-with-the-frame-s.md index e34e6b843..2cf4948b6 100644 --- a/.abcd/work/issues/open/iss-2609252243286219-capture-reframe-s-whole-write-refuses-with-the-frame-s.md +++ b/.abcd/work/issues/resolved/iss-2609252243286219-capture-reframe-s-whole-write-refuses-with-the-frame-s.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/capture/reframe.go" +resolution: "a whole write with no distinct state inside the fingerprintable history refuses naming that, how many commits the history reaches back and to which commit, and why the state before cannot be compared" +impact: fix +resolved_by: + commit: "44ad3c43" --- capture reframe's whole write refuses with 'the frame's previous state cannot be fingerprinted: carries no H2 section titled Construal' when no distinct prior state lies within the history that carries a Construal section, instead of naming the true reason: no prior distinct state within the fingerprintable history, and how far back that history reaches + +## Grounds + +- pursued: a history whose older states carry no Construal section refuses with 'matches no prior committed state within its fingerprintable history' and the reach; a refusal naming a fingerprint failure would show it wrong From 29a9a18f5f224b057f7e475593257c56e7f112df Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:53:14 +0100 Subject: [PATCH 44/78] =?UTF-8?q?chore:=20resolve=20iss-2609252243299568?= =?UTF-8?q?=20=E2=80=94=20the=20comparative=20rows=20declare=20their=20lea?= =?UTF-8?q?k=20half=20a=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252243299568 Assisted-by: Claude:claude-opus-5-5 --- ...-four-comparative-coverage-rows-for-the-reframe-and.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252243299568-the-four-comparative-coverage-rows-for-the-reframe-and.md (59%) diff --git a/.abcd/work/issues/open/iss-2609252243299568-the-four-comparative-coverage-rows-for-the-reframe-and.md b/.abcd/work/issues/resolved/iss-2609252243299568-the-four-comparative-coverage-rows-for-the-reframe-and.md similarity index 59% rename from .abcd/work/issues/open/iss-2609252243299568-the-four-comparative-coverage-rows-for-the-reframe-and.md rename to .abcd/work/issues/resolved/iss-2609252243299568-the-four-comparative-coverage-rows-for-the-reframe-and.md index 3853f49ea..731030539 100644 --- a/.abcd/work/issues/open/iss-2609252243299568-the-four-comparative-coverage-rows-for-the-reframe-and.md +++ b/.abcd/work/issues/resolved/iss-2609252243299568-the-four-comparative-coverage-rows-for-the-reframe-and.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "evals/coldreading_coverage_test.go" +resolution: "each comparative derived-family row is split into its leak half, declared a Gap with the reason no plant can die there, and its manifest half, caught by path; the row and gap pins move 80 to 84 and 7 to 11" +impact: internal +resolved_by: + commit: "3d06143e" --- The four comparative coverage rows for the reframe and derived families state a two-clause Rule (never reach the bundle AND the manifest says so) while only the manifest clause is falsified; the unfalsified leak half is disclosed in a code comment, not in the row's Gap field the matrix header names as the place for an unfalsifiable claim + +## Grounds + +- pursued: every coverage row states one rule and is either caught or a declared gap; a row whose Rule claims a leak its Caught mechanism does not produce would show it wrong From efe9ba9b1a496f6ae47b6d8205a39a4c28ac19ad Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:53:15 +0100 Subject: [PATCH 45/78] =?UTF-8?q?chore:=20resolve=20iss-2609252243290965?= =?UTF-8?q?=20=E2=80=94=20record-lint=20names=20a=20reframe=20outside=20it?= =?UTF-8?q?s=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252243290965 Assisted-by: Claude:claude-opus-5-5 --- ...eading-exclusion-of-the-reframe-record-is-by-path-a.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252243290965-cold-reading-exclusion-of-the-reframe-record-is-by-path-a.md (57%) diff --git a/.abcd/work/issues/open/iss-2609252243290965-cold-reading-exclusion-of-the-reframe-record-is-by-path-a.md b/.abcd/work/issues/resolved/iss-2609252243290965-cold-reading-exclusion-of-the-reframe-record-is-by-path-a.md similarity index 57% rename from .abcd/work/issues/open/iss-2609252243290965-cold-reading-exclusion-of-the-reframe-record-is-by-path-a.md rename to .abcd/work/issues/resolved/iss-2609252243290965-cold-reading-exclusion-of-the-reframe-record-is-by-path-a.md index 580983322..81abe159c 100644 --- a/.abcd/work/issues/open/iss-2609252243290965-cold-reading-exclusion-of-the-reframe-record-is-by-path-a.md +++ b/.abcd/work/issues/resolved/iss-2609252243290965-cold-reading-exclusion-of-the-reframe-record-is-by-path-a.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/lint" +resolution: "record-lint's cross_store_id_claim names a reframe-shaped file (rfm-N name, rfm-N id, or occasioned_by beside a before fingerprint) outside the reframe store as a blocker" +impact: fix +resolved_by: + commit: "cbb54351" --- Cold-reading exclusion of the reframe record is by path: a reframe-shaped file (an rfm-N name or reframe frontmatter) planted outside .abcd/work/issues/reframes/, e.g. as a brief chapter, reaches every cold reading with its grounds and body, and no gate names it, where the open/ look-alike is flagged as a mis-named issue + +## Grounds + +- pursued: a reframe planted as a brief chapter in a tracked tree makes record-lint exit 1 naming it, while the store, a nested fixture store and a prose mention stay clean; a planted reframe the gate passes would show it wrong From b8d632dbcf2b4eb1f6b6fd5cfb5531db7c98f240 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:37:06 +0100 Subject: [PATCH 46/78] chore: capture review-scribe's findings against the scribe verbs Six findings from the review of the scribe lane, captured before any fix. The duplicate-key finding carries a recorded deferral to the integration step: the strict decoder it needs lives on the unmerged lintB lane. Refs: iss-2609261036355193 Refs: iss-2609261036354146 Refs: iss-2609261036355918 Refs: iss-2609261036363663 Refs: iss-2609261036363114 Refs: iss-2609261036366114 Assisted-by: Claude:claude-opus-5-5 --- ...enticates-nothing-about-the-parked-context.md | 14 ++++++++++++++ ...t-never-holds-a-disposition-s-state-to-the.md | 14 ++++++++++++++ ...otes-the-manifest-beside-the-run-even-when.md | 14 ++++++++++++++ ...codes-its-payload-with-plain-encoding-json.md | 16 ++++++++++++++++ ...fuses-a-symlink-at-an-allow-list-directory.md | 14 ++++++++++++++ ...n-renders-refusals-reason-refusals-subject.md | 14 ++++++++++++++ 6 files changed, 86 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609261036354146-scribe-ingest-authenticates-nothing-about-the-parked-context.md create mode 100644 .abcd/work/issues/open/iss-2609261036355193-scribe-ingest-never-holds-a-disposition-s-state-to-the.md create mode 100644 .abcd/work/issues/open/iss-2609261036355918-scribe-ingest-promotes-the-manifest-beside-the-run-even-when.md create mode 100644 .abcd/work/issues/open/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md create mode 100644 .abcd/work/issues/open/iss-2609261036363663-scribe-assemble-refuses-a-symlink-at-an-allow-list-directory.md create mode 100644 .abcd/work/issues/open/iss-2609261036366114-scribe-ingest-json-renders-refusals-reason-refusals-subject.md diff --git a/.abcd/work/issues/open/iss-2609261036354146-scribe-ingest-authenticates-nothing-about-the-parked-context.md b/.abcd/work/issues/open/iss-2609261036354146-scribe-ingest-authenticates-nothing-about-the-parked-context.md new file mode 100644 index 000000000..600cf5229 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261036354146-scribe-ingest-authenticates-nothing-about-the-parked-context.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609261036354146" +slug: "scribe-ingest-authenticates-nothing-about-the-parked-context" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-scribe" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/scribe/ingest.go" +--- + +scribe ingest authenticates nothing about the parked context and manifest pair: a scribe that rewrites context.json's supplied dispositions and recomputes the manifest's context hash passes the verbatim check against text it wrote itself, while manifest.supplied.dispositions_sha256 is recorded and never read diff --git a/.abcd/work/issues/open/iss-2609261036355193-scribe-ingest-never-holds-a-disposition-s-state-to-the.md b/.abcd/work/issues/open/iss-2609261036355193-scribe-ingest-never-holds-a-disposition-s-state-to-the.md new file mode 100644 index 000000000..2f1c29a8e --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261036355193-scribe-ingest-never-holds-a-disposition-s-state-to-the.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609261036355193" +slug: "scribe-ingest-never-holds-a-disposition-s-state-to-the" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-scribe" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/scribe/ingest.go" +--- + +scribe ingest never holds a disposition's state to the supplied text: a researcher line 'rdi-X: rejected — <ground>' ingests as state accepted with the verbatim ground, so the ruling itself is the one field the scribe can author, against the spec's out-of-scope rule that a state the material does not carry is refused, never supplied diff --git a/.abcd/work/issues/open/iss-2609261036355918-scribe-ingest-promotes-the-manifest-beside-the-run-even-when.md b/.abcd/work/issues/open/iss-2609261036355918-scribe-ingest-promotes-the-manifest-beside-the-run-even-when.md new file mode 100644 index 000000000..fd7d5d255 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261036355918-scribe-ingest-promotes-the-manifest-beside-the-run-even-when.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609261036355918" +slug: "scribe-ingest-promotes-the-manifest-beside-the-run-even-when" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-scribe" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/scribe/ingest.go" +--- + +scribe ingest promotes the manifest beside the run even when no record landed, so a payload of refusals or outstanding items alone locks the run against every later scribe session over it diff --git a/.abcd/work/issues/open/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md b/.abcd/work/issues/open/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md new file mode 100644 index 000000000..745a8daa3 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2609261036363114" +slug: "scribe-ingest-decodes-its-payload-with-plain-encoding-json" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-scribe" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/scribe/ingest.go" +deferred_after: "v0.10.0" +deferral_reason: "deferred to the integration step (run A, 2026-09-26): the strict duplicate-key decoder jsonstrict lives on the unmerged lintB lane and copying it here would fork it; once lintB lands, decodeOutput in internal/core/scribe/ingest.go reroutes its decode through jsonstrict at every depth, refusing duplicate keys and case-folded twins, and this record is resolved there. Until then the state a duplicate key selects is still held to the item's own line of the supplied dispositions, and every free text to the supplied text verbatim" +--- + +scribe ingest decodes its payload with plain encoding/json, so a duplicate key at any depth takes the last value: {"state":"rejected","state":"accepted"} decodes as accepted rather than being refused diff --git a/.abcd/work/issues/open/iss-2609261036363663-scribe-assemble-refuses-a-symlink-at-an-allow-list-directory.md b/.abcd/work/issues/open/iss-2609261036363663-scribe-assemble-refuses-a-symlink-at-an-allow-list-directory.md new file mode 100644 index 000000000..cc406927d --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261036363663-scribe-assemble-refuses-a-symlink-at-an-allow-list-directory.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609261036363663" +slug: "scribe-assemble-refuses-a-symlink-at-an-allow-list-directory" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-scribe" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/scribe/assemble.go" +--- + +scribe assemble refuses a symlink at an allow-list directory and inside it but not at its ancestors (.abcd, .abcd/work, .abcd/work/issues): os.Root follows an in-root link, so a committed issues -> docs/shadow link carries shipped-tree content into the scribe context under ledger paths diff --git a/.abcd/work/issues/open/iss-2609261036366114-scribe-ingest-json-renders-refusals-reason-refusals-subject.md b/.abcd/work/issues/open/iss-2609261036366114-scribe-ingest-json-renders-refusals-reason-refusals-subject.md new file mode 100644 index 000000000..1bf85f052 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261036366114-scribe-ingest-json-renders-refusals-reason-refusals-subject.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609261036366114" +slug: "scribe-ingest-json-renders-refusals-reason-refusals-subject" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-scribe" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/cli/scribe.go" +--- + +scribe ingest --json renders refusals[].reason, refusals[].subject and the fidelity flags, which are scribe free text, raw: the text render neutralises them through termsafe and the JSON render leaves bidi and C1 controls in place (the iss-359 class) From c164a129fe2ed84514fb677eb14a046d157a1c04 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:39:18 +0100 Subject: [PATCH 47/78] chore: capture review-principles' three findings on the principle readers The review of the principles lane found the statement's two readers, the reading projection and the principle_claims lint, disagreeing on what a principle's statement is: a heading-shaped statement, the H1 title, and links that are not inline. Refs: iss-2609261039132350, iss-2609261039134673, iss-2609261039139464 Assisted-by: Claude:claude-opus-5-5 --- ...se-statement-is-written-as-an-h2-heading-the.md | 14 ++++++++++++++ ...ims-judges-only-the-the-rule-paragraph-while.md | 14 ++++++++++++++ ...ement-s-citation-check-reads-links-as-inline.md | 14 ++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609261039132350-a-principle-whose-statement-is-written-as-an-h2-heading-the.md create mode 100644 .abcd/work/issues/open/iss-2609261039134673-principle-claims-judges-only-the-the-rule-paragraph-while.md create mode 100644 .abcd/work/issues/open/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md diff --git a/.abcd/work/issues/open/iss-2609261039132350-a-principle-whose-statement-is-written-as-an-h2-heading-the.md b/.abcd/work/issues/open/iss-2609261039132350-a-principle-whose-statement-is-written-as-an-h2-heading-the.md new file mode 100644 index 000000000..f68d4b6c3 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261039132350-a-principle-whose-statement-is-written-as-an-h2-heading-the.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609261039132350" +slug: "a-principle-whose-statement-is-written-as-an-h2-heading-the" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-principles" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/reading/project.go" +--- + +A principle whose statement is written as an H2 heading (## The rule) is projected by projectField's heading leg before the labelled-paragraph leg, so the whole section travels to a reading, its Why and Bounds included, under a manifest naming the field The rule; the lint judges only the labelled paragraph, so no gate refuses the shape and the floor's statement-alone promise breaks on a file shape. diff --git a/.abcd/work/issues/open/iss-2609261039134673-principle-claims-judges-only-the-the-rule-paragraph-while.md b/.abcd/work/issues/open/iss-2609261039134673-principle-claims-judges-only-the-the-rule-paragraph-while.md new file mode 100644 index 000000000..5fdfcc341 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261039134673-principle-claims-judges-only-the-the-rule-paragraph-while.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609261039134673" +slug: "principle-claims-judges-only-the-the-rule-paragraph-while" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-principles" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lint/principles.go" +--- + +principle_claims judges only the **The rule.** paragraph while the projection sends the H1 title above it and verifyPrincipleItem scans both, so a typed principle whose title cites a record (# Title citing itd-79) passes record-lint and then refuses the whole reading assembly: the two readers of a principle's statement disagree about what it is. diff --git a/.abcd/work/issues/open/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md b/.abcd/work/issues/open/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md new file mode 100644 index 000000000..d0eb7f4b5 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609261039139464" +slug: "a-principle-statement-s-citation-check-reads-links-as-inline" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-principles" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/reading/project.go" +--- + +A principle statement's citation check reads links as inline [label](target) only: a bare URL, an autolink <https://...> and a reference-style [label][ref] travel into the bundle raw and the lint is silent on all three, while the manifest's exclusion row asserts record handles and links in a principle stay behind. From 1d82650c6341b2408f998e7574ea74157b53c1aa Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:42:10 +0100 Subject: [PATCH 48/78] fix(scribe): hold a disposition's state to the item's own supplied line The ingest held every ground to the supplied text and left the state, the ruling itself, to the definition: a researcher line "rdi-X: rejected" landed as accepted with the verbatim ground. The state must now stand as a whole word, in any case, on a line of the supplied dispositions that names the item, so a state another item's line carries is refused as well. An admission writes an accepted disposition, so the same rule holds it: the item's line must admit or accept the proposal (the sibling sweep's find). The chapter's disclosed limit no longer documents the hole; it states the residue instead (the check reads words, not sense). Refs: iss-2609261036355193 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/31-scribe.md | 21 ++++--- commands/scribe.md | 4 +- docs/reference/cli/commands.md | 9 +-- internal/core/scribe/ingest.go | 52 ++++++++++++++++ internal/core/scribe/ingest_test.go | 59 +++++++++++++++++++ internal/surface/cli/scribe.go | 9 +-- 6 files changed, 137 insertions(+), 17 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/31-scribe.md b/.abcd/development/brief/04-surfaces/31-scribe.md index ff906ad4c..4857207f0 100644 --- a/.abcd/development/brief/04-surfaces/31-scribe.md +++ b/.abcd/development/brief/04-surfaces/31-scribe.md @@ -77,10 +77,14 @@ until all of the following hold: - **Nothing is authored.** The payload is decoded against closed shapes at every level, so a key the scribe may not author is refused by name with the entry it sat on. A disposition or an admission for an item the supplied text never names - is one the researcher did not supply. A ground, an exit condition or a surprise - that does not stand verbatim in the supplied text, once whitespace is folded, - is one the researcher did not write. The scribe reformats; the check is that - every word it carries was already there. + is one the researcher did not supply. A disposition's state must stand as a + whole word, in any case, on a line of the supplied text that names its item, + and an admission, which writes an acceptance, needs that line to admit or + accept the proposal: the state is the ruling, and a state another item's line + carries is not the researcher's answer to this one. A ground, an exit + condition or a surprise that does not stand verbatim in the supplied text, + once whitespace is folded, is one the researcher did not write. The scribe + reformats; the check is that every word it carries was already there. - **Every answer is the run's, once.** An answered or outstanding item must be one of the run's items, and one item takes one answer. - **Nothing is passed over in silence.** Every item of the run with no standing @@ -117,10 +121,11 @@ renders what landed first. the obligation, and the separation check can only see what a host retained: where a host assembles context before anything is retained, the check reports the property unobserved and the scribe definition's protocol remains the gate. -- The state a disposition carries is judged by the capture verbs' vocabulary and - per-position rule, not against the supplied text: the verb refuses a ground the - researcher did not write, and the definition, not the verb, holds the scribe to - the state the researcher gave. +- The state check reads words, not sense. A line that names a state only to + negate it ("rdi-N: not accepted") still carries it, and a line naming two + states carries both, so the verb refuses a state the item's line does not + carry and cannot tell which of two it carries the researcher meant. The + definition holds the scribe to the ruling the line gives. ## References diff --git a/commands/scribe.md b/commands/scribe.md index 459ad8548..050b84cd8 100644 --- a/commands/scribe.md +++ b/commands/scribe.md @@ -100,7 +100,9 @@ hold, and any failure exits 2 naming the field and the item: - **Nothing is authored**: a key outside the shapes above (a `resolution`, a `pattern`, a `position`, anything) is refused by name; a disposition or an admission for an item the supplied dispositions never name is refused; a - `grounds`, an `exit_condition` or a surprise `text` that does not stand + `state` that does not stand as a whole word on a line naming its item is + refused, and so is an admission whose item's line neither admits nor accepts + it; a `grounds`, an `exit_condition` or a surprise `text` that does not stand verbatim in the supplied text once whitespace is folded is refused. The scribe reformats; it never adds a word. - **Every answer is the run's**: a disposition, admission or outstanding item diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index b539baf5d..39f0b4063 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1589,10 +1589,11 @@ Validate the JSON a scribe session returned and write its records through the ca The context the session was handed is proven first: it must hash to its parked manifest, and the output must cite that hash. Then the output is refused if the scribe authored anything — -a field outside the declared shapes, an item the supplied dispositions never name, or a ground, -exit condition or surprise that does not stand verbatim in the supplied text once whitespace -is folded — or if it passes over an unanswered item of the run in silence. Nothing is written -until all of that holds. +a field outside the declared shapes, an item the supplied dispositions never name, a state or an +admission the item's own line of the supplied text does not carry, or a ground, exit condition +or surprise that does not stand verbatim in the supplied text once whitespace is folded — or if +it passes over an unanswered item of the run in silence. Nothing is written until all of that +holds. Dispositions, admissions and surprises are then written in that order through the capture verbs, which apply their own redaction and refusals, the ordering gate included; the first refusal stops diff --git a/internal/core/scribe/ingest.go b/internal/core/scribe/ingest.go index 0c3ee96ea..e948484cf 100644 --- a/internal/core/scribe/ingest.go +++ b/internal/core/scribe/ingest.go @@ -452,6 +452,15 @@ func refuseAuthored(out Output, supplied string, items map[string]bool) error { return fmt.Errorf("scribe: %s is a disposition the researcher did not supply: the supplied "+ "dispositions never name %s", where, echo(d.Item)) } + // The state is the ruling itself, so it is held to the supplied text as + // the grounds are, and more tightly: it must stand whole-word on a line + // that names the item, because a state another item's line carries is not + // the researcher's answer to this one. + if !lineCarries(supplied, d.Item, d.State) { + return fmt.Errorf("scribe: %s carries state %q, and no line of the supplied dispositions that names "+ + "%s carries it; the state is the researcher's ruling and the scribe never supplies one, so the "+ + "payload is refused and nothing is written", where, echo(d.State), echo(d.Item)) + } for _, id := range append([]string{d.Supersedes}, d.Recurs...) { if id != "" && !named(id) { return fmt.Errorf("scribe: %s cites %s, which the supplied dispositions never name", where, echo(id)) @@ -476,6 +485,14 @@ func refuseAuthored(out Output, supplied string, items map[string]bool) error { return fmt.Errorf("scribe: %s is an admission the researcher did not supply: the supplied "+ "dispositions never name %s", where, echo(a.Item)) } + // An admission writes an accepted disposition, so it is a state too, held + // by the same rule: the item's own line admits or accepts the proposal. + if !lineCarries(supplied, a.Item, admissionTokens...) { + return fmt.Errorf("scribe: %s is an admission, and no line of the supplied dispositions that names "+ + "%s admits or accepts it (%s); an admission writes an acceptance, which is the researcher's "+ + "ruling and never the scribe's, so the payload is refused and nothing is written", + where, echo(a.Item), strings.Join(admissionTokens, ", ")) + } if err := verbatim(where, "grounds", a.Grounds); err != nil { return err } @@ -540,6 +557,41 @@ func refuseAuthored(out Output, supplied string, items map[string]bool) error { return nil } +// admissionTokens are the words that carry an admission on an item's line. At +// the widening position acceptance IS admission, so the state's own name counts +// beside the verb's forms. +var admissionTokens = []string{issueschema.DispositionAccepted, "admit", "admits", "admitted"} + +// lineCarries reports whether some line of supplied that names id carries one of +// tokens as a whole word, ignoring case. It is the mechanical form of "the +// researcher gave this item this ruling": the token must sit on the item's own +// line, and a word that merely contains it ("unaccepted") does not carry it. It +// reads words, not sense, so a line that names a state to negate it still +// carries it; that residue is the chapter's to disclose. +func lineCarries(supplied, id string, tokens ...string) bool { + for _, line := range strings.Split(supplied, "\n") { + if !mentions(line, id) { + continue + } + for _, tok := range tokens { + if tok != "" && wholeWord(line, tok) { + return true + } + } + } + return false +} + +// wholeWord reports whether text holds word, ignoring case, bounded on each side +// by the text's edge or a character that is neither a letter nor a digit. +func wholeWord(text, word string) bool { + re, err := regexp.Compile(`(?i)(^|[^\pL\pN])` + regexp.QuoteMeta(word) + `($|[^\pL\pN])`) + if err != nil { + return false + } + return re.MatchString(text) +} + // fold collapses every run of whitespace to one space and trims the ends, so a // re-wrapped sentence is the same words. func fold(s string) string { return strings.Join(strings.Fields(s), " ") } diff --git a/internal/core/scribe/ingest_test.go b/internal/core/scribe/ingest_test.go index b72eb71e4..da2a96e9c 100644 --- a/internal/core/scribe/ingest_test.go +++ b/internal/core/scribe/ingest_test.go @@ -465,3 +465,62 @@ func TestScribeIngestWritesAdmissionsAndSurprises(t *testing.T) { t.Fatalf("an unsupplied surprise was not refused: %v", err) } } + +// TestScribeIngestHoldsTheStateToTheItemsLine: the state is the ruling, and the +// scribe may not author it. A disposition's state must stand, whole-word, on a +// line of the supplied text that names its item: a state another item's line +// carries is not the researcher's answer to this one, and a word that merely +// contains the state is not the state. +func TestScribeIngestHoldsTheStateToTheItemsLine(t *testing.T) { + s := assembleSession(t, positionDetection, 2, + "{0}: rejected — "+groundA+".\n{1}: accepted — "+groundA+".\n") + before := s.ledger(t) + o := s.out() + o.Dispositions = []OutDisposition{ + {Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}, + {Item: s.items[1], State: issueschema.DispositionAccepted, Grounds: groundA}, + } + _, err := s.ingest(t, s.write(t, o)) + if err == nil || !strings.Contains(err.Error(), "state") || !strings.Contains(err.Error(), s.items[0]) { + t.Fatalf("a state the item's line does not carry was not refused naming the state and the item: %v", err) + } + if s.ledger(t) != before { + t.Fatal("a refused payload changed the ledger") + } + + // Whole-word: "unaccepted" does not carry "accepted". + s2 := assembleSession(t, positionDetection, 1, "{0}: unaccepted — "+groundA+".\n") + o2 := s2.out() + o2.Dispositions = []OutDisposition{{Item: s2.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}} + if _, err := s2.ingest(t, s2.write(t, o2)); err == nil || !strings.Contains(err.Error(), "state") { + t.Fatalf("a state carried only inside a longer word was not refused: %v", err) + } + + // The state the line does carry lands, whatever its case. + s3 := assembleSession(t, positionDetection, 1, "{0}: Accepted — "+groundA+".\n") + o3 := s3.out() + o3.Dispositions = []OutDisposition{{Item: s3.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}} + if _, err := s3.ingest(t, s3.write(t, o3)); err != nil { + t.Fatalf("a state the item's line carries was refused: %v", err) + } +} + +// TestScribeIngestHoldsAnAdmissionToTheItemsLine: an admission writes an +// accepted disposition, so it is a state too, and the same rule holds it: the +// item's own line must admit or accept the proposal. +func TestScribeIngestHoldsAnAdmissionToTheItemsLine(t *testing.T) { + s := assembleSession(t, issueschema.PositionWidening, 2, + "{0}: declined — "+groundA+".\n{1}: admit it — "+groundA+".\n") + writeFile(t, s.repo, filepath.Join(issueschema.ReadingsRecordDir, "rdg-2609250000000009", issueschema.RunRecordFileName), + `{"run_id":"rdg-2609250000000009","position":"comparative","candidate_run":"`+fixtureRun+`"}`) + before := s.ledger(t) + o := s.out() + o.Admissions = []OutAdmission{{Item: s.items[0], Grounds: groundA}, {Item: s.items[1], Grounds: groundA}} + _, err := s.ingest(t, s.write(t, o)) + if err == nil || !strings.Contains(err.Error(), "admission") || !strings.Contains(err.Error(), s.items[0]) { + t.Fatalf("an admission the item's line does not carry was not refused: %v", err) + } + if s.ledger(t) != before { + t.Fatal("a refused payload changed the ledger") + } +} diff --git a/internal/surface/cli/scribe.go b/internal/surface/cli/scribe.go index 9d43947c3..61e8c2ec7 100644 --- a/internal/surface/cli/scribe.go +++ b/internal/surface/cli/scribe.go @@ -116,10 +116,11 @@ func newScribeCommand(asJSON *bool) *cobra.Command { Long: "Validate the JSON a scribe session returned and write its records through the capture verbs.\n\n" + "The context the session was handed is proven first: it must hash to its parked manifest, and\n" + "the output must cite that hash. Then the output is refused if the scribe authored anything —\n" + - "a field outside the declared shapes, an item the supplied dispositions never name, or a ground,\n" + - "exit condition or surprise that does not stand verbatim in the supplied text once whitespace\n" + - "is folded — or if it passes over an unanswered item of the run in silence. Nothing is written\n" + - "until all of that holds.\n\n" + + "a field outside the declared shapes, an item the supplied dispositions never name, a state or an\n" + + "admission the item's own line of the supplied text does not carry, or a ground, exit condition\n" + + "or surprise that does not stand verbatim in the supplied text once whitespace is folded — or if\n" + + "it passes over an unanswered item of the run in silence. Nothing is written until all of that\n" + + "holds.\n\n" + "Dispositions, admissions and surprises are then written in that order through the capture verbs,\n" + "which apply their own redaction and refusals, the ordering gate included; the first refusal stops\n" + "the ingest and names what landed before it. Fidelity flags and refusals are reported and never\n" + From aaaedc28b2fb738347b4731b0407a1114e1ef340 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:42:36 +0100 Subject: [PATCH 49/78] =?UTF-8?q?chore:=20resolve=20iss-2609261036355193?= =?UTF-8?q?=20=E2=80=94=20the=20state=20is=20held=20to=20the=20item's=20li?= =?UTF-8?q?ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scribe verbs have not reached a release, so the fix is internal to the cut that ships them. Resolves: iss-2609261036355193 Assisted-by: Claude:claude-opus-5-5 --- ...ibe-ingest-never-holds-a-disposition-s-state-to-the.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261036355193-scribe-ingest-never-holds-a-disposition-s-state-to-the.md (56%) diff --git a/.abcd/work/issues/open/iss-2609261036355193-scribe-ingest-never-holds-a-disposition-s-state-to-the.md b/.abcd/work/issues/resolved/iss-2609261036355193-scribe-ingest-never-holds-a-disposition-s-state-to-the.md similarity index 56% rename from .abcd/work/issues/open/iss-2609261036355193-scribe-ingest-never-holds-a-disposition-s-state-to-the.md rename to .abcd/work/issues/resolved/iss-2609261036355193-scribe-ingest-never-holds-a-disposition-s-state-to-the.md index 2f1c29a8e..d9a4ae0f8 100644 --- a/.abcd/work/issues/open/iss-2609261036355193-scribe-ingest-never-holds-a-disposition-s-state-to-the.md +++ b/.abcd/work/issues/resolved/iss-2609261036355193-scribe-ingest-never-holds-a-disposition-s-state-to-the.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-scribe" origin: researcher-authored production_mode: hand-written found_at: "internal/core/scribe/ingest.go" +resolution: "scribe ingest holds a disposition's state, whole-word and in any case, to a line of the supplied dispositions that names the item, and an admission to a line that admits or accepts it; the chapter's disclosed limit states the residue (words, not sense)" +impact: internal +resolved_by: + commit: "1d82650c" --- scribe ingest never holds a disposition's state to the supplied text: a researcher line 'rdi-X: rejected — <ground>' ingests as state accepted with the verbatim ground, so the ruling itself is the one field the scribe can author, against the spec's out-of-scope rule that a state the material does not carry is refused, never supplied + +## Grounds + +- pursued: a payload state the item's own supplied line does not carry is refused before anything is written; a payload that lands a state no line naming its item carries would show it wrong From 21dce52972998b226a4b3f10eb7f802182353e77 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:47:44 +0100 Subject: [PATCH 50/78] fix(scribe): authenticate the parked pair against the researcher's own text The parked context was the only witness to what the researcher wrote, and nothing authenticated it: a scribe that rewrote the context's supplied text and recomputed the manifest's context hash passed the verbatim check against words it wrote, while manifest.supplied.dispositions_sha256 was recorded and never read. Checking that recorded hash alone would not close it, because the manifest is parked beside the context and a scribe that rewrites one can rewrite both. So ingest takes --dispositions again, the researcher's file that assemble was handed and the session never was: it is re-read and scrubbed as assemble scrubbed it, the manifest's supplied hash and the context's supplied copy must both equal it, and every verbatim, naming and state check reads it. This is the smallest shape that leaves a scribe-authored ground unverifiable: it needs no new artefact and no copied hash, and it binds the checks to the researcher's own bytes rather than to anything the session could reach. The residue (a session that finds and rewrites that file) is disclosed in the chapter and covered by the host obligation. Refs: iss-2609261036354146 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/31-scribe.md | 13 ++++ .abcd/development/release/surface.json | 7 ++ commands/scribe.md | 14 +++- docs/reference/cli/commands.md | 16 ++-- internal/core/scribe/ingest.go | 42 ++++++++++- internal/core/scribe/ingest_test.go | 73 ++++++++++++++++++- internal/surface/cli/scribe.go | 25 +++++-- internal/surface/cli/scribe_surface_test.go | 4 +- 8 files changed, 171 insertions(+), 23 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/31-scribe.md b/.abcd/development/brief/04-surfaces/31-scribe.md index 4857207f0..8b56b9297 100644 --- a/.abcd/development/brief/04-surfaces/31-scribe.md +++ b/.abcd/development/brief/04-surfaces/31-scribe.md @@ -74,6 +74,14 @@ until all of the following hold: - **The context is proven.** The context on disk hashes to its parked manifest, and the payload cites that hash. +- **The supplied text is the researcher's.** The context and the manifest are + parked in the local tier, where a scribe session granted tools could rewrite + both and recompute every hash that binds them, so their agreement is no + witness to what the researcher wrote. The ingest therefore takes the + researcher's dispositions file again, the one assemble was handed and the + session never was, scrubs it as assemble did, and requires the manifest's + supplied hash and the context's supplied copy both to equal it. Every check + below reads that text. - **Nothing is authored.** The payload is decoded against closed shapes at every level, so a key the scribe may not author is refused by name with the entry it sat on. A disposition or an admission for an item the supplied text never names @@ -121,6 +129,10 @@ renders what landed first. the obligation, and the separation check can only see what a host retained: where a host assembles context before anything is retained, the check reports the property unobserved and the scribe definition's protocol remains the gate. +- The researcher's dispositions file is the ingest's one witness, and it is a + file the operator names. A scribe session that learns its path and rewrites + it before the ingest is outside what the verb can see; the host obligation + covers it, since the session is handed the context and nothing else. - The state check reads words, not sense. A line that names a state only to negate it ("rdi-N: not accepted") still carries it, and a line naming two states carries both, so the verb refuses a state the item's line does not @@ -164,6 +176,7 @@ Sub-verbs: none. | Flag | Type | |---|---| | `--context` | string | +| `--dispositions` | string | | `--scribe-json` | string | <!-- surface-appendix:end --> diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index ab659a649..c9ed6ae69 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -1774,6 +1774,13 @@ "required": false, "hidden": false }, + { + "name": "dispositions", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, { "name": "scribe-json", "shorthand": "", diff --git a/commands/scribe.md b/commands/scribe.md index 050b84cd8..415455e13 100644 --- a/commands/scribe.md +++ b/commands/scribe.md @@ -1,7 +1,7 @@ --- name: scribe description: Build the ledger scribe's context from the ledger alone and ingest what the scribe transcribed, by invoking the abcd binary. assemble parks the context and a hashed manifest in the local tier and touches nothing durable; ingest validates the scribe's output, refuses anything the scribe authored, writes dispositions, admissions and surprises through the capture verbs, and promotes the manifest beside the run. -argument-hint: "assemble --run <rdg-N> --dispositions <path> [--out <dir>] [--dry-run] | ingest --scribe-json <path> [--context <path>]" +argument-hint: "assemble --run <rdg-N> --dispositions <path> [--out <dir>] [--dry-run] | ingest --scribe-json <path> --dispositions <path> [--context <path>]" --- # `/abcd:scribe` — the ledger scribe's context and ingest @@ -88,15 +88,21 @@ context. ## Ingest ```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" scribe ingest --scribe-json ./scribe-output.json --json +"${CLAUDE_PLUGIN_ROOT}/abcd" scribe ingest --scribe-json ./scribe-output.json --dispositions ./dispositions.md --json ``` -`--context` names the context when `assemble` wrote it under `--out`; the -manifest is read from beside it. Nothing is written until all of the following +`--dispositions` names the researcher's dispositions text again, the same file +`assemble` was handed, and is required. `--context` names the context when +`assemble` wrote it under `--out`; the manifest is read from beside it. Nothing is written until all of the following hold, and any failure exits 2 naming the field and the item: - **The context is proven**: it hashes to its parked manifest, and the payload cites that hash. A payload from another session is refused. +- **The supplied text is the researcher's**: the parked context and manifest sit + where a scribe session with tools could rewrite them, so the manifest's + supplied hash and the context's supplied copy must both equal the file + `--dispositions` names, and every check below reads that file. Never hand + the scribe session that file's path. - **Nothing is authored**: a key outside the shapes above (a `resolution`, a `pattern`, a `position`, anything) is refused by name; a disposition or an admission for an item the supplied dispositions never name is refused; a diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 39f0b4063..6eda9569f 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1583,12 +1583,15 @@ abcd scribe assemble --run rdg-2609250000000001 --dispositions ./dispositions.md Validate a scribe session's output and write what it transcribed -**Usage:** `abcd scribe ingest --scribe-json <path> [flags]` +**Usage:** `abcd scribe ingest --scribe-json <path> --dispositions <path> [flags]` Validate the JSON a scribe session returned and write its records through the capture verbs. The context the session was handed is proven first: it must hash to its parked manifest, and -the output must cite that hash. Then the output is refused if the scribe authored anything — +the output must cite that hash. The pair is parked where a scribe session could rewrite it, so +--dispositions names the researcher's own text again, the file assemble was handed: the +manifest's supplied hash and the context's supplied copy must both equal it, and every check +below reads it. Then the output is refused if the scribe authored anything — a field outside the declared shapes, an item the supplied dispositions never name, a state or an admission the item's own line of the supplied text does not carry, or a ground, exit condition or surprise that does not stand verbatim in the supplied text once whitespace is folded — or if @@ -1603,15 +1606,16 @@ written. Once every write has landed the manifest is promoted beside the run, wr **Flags:** ``` - --context string the context the session was handed, when assemble wrote it under --out - (default: the local-tier scribe run directory of the output's run) - --scribe-json string path to the JSON the scribe session returned + --context string the context the session was handed, when assemble wrote it under --out + (default: the local-tier scribe run directory of the output's run) + --dispositions string the researcher's dispositions text, the file assemble was handed + --scribe-json string path to the JSON the scribe session returned ``` **Example:** ``` -abcd scribe ingest --scribe-json ./scribe-output.json --json +abcd scribe ingest --scribe-json ./scribe-output.json --dispositions ./dispositions.md --json ``` ### `abcd site` diff --git a/internal/core/scribe/ingest.go b/internal/core/scribe/ingest.go index e948484cf..08f5e1f24 100644 --- a/internal/core/scribe/ingest.go +++ b/internal/core/scribe/ingest.go @@ -43,6 +43,11 @@ type IngestRequest struct { // ContextPath is the context the session was handed; empty means the local- // tier default for the payload's run. The manifest is read from beside it. ContextPath string + // DispositionsPath is the researcher's dispositions text, the file assemble + // was handed. It is required: the parked pair sits where a scribe session + // with tools can rewrite it, so the verbatim checks read the researcher's + // own file, and the parked copy and the manifest's hash are held to it. + DispositionsPath string } // OutDisposition is one disposition the scribe transcribed. @@ -155,6 +160,11 @@ func Ingest(req IngestRequest) (IngestResult, error) { if strings.TrimSpace(req.ScribeJSONPath) == "" { return IngestResult{}, errors.New("scribe: no scribe output named") } + if strings.TrimSpace(req.DispositionsPath) == "" { + return IngestResult{}, errors.New("scribe: no dispositions supplied; the ingest holds every word the " + + "scribe carries to the researcher's own dispositions text, the file assemble was handed, and the " + + "copy parked beside the context is no witness to it") + } raw, err := fsutil.ReadGuarded(req.ScribeJSONPath, reading.MaxFileBytes) if err != nil { return IngestResult{}, fmt.Errorf("scribe: reading the scribe output: %w", err) @@ -177,6 +187,10 @@ func Ingest(req IngestRequest) (IngestResult, error) { if err != nil { return IngestResult{}, err } + supplied, err := proveSupplied(req, ctx, m) + if err != nil { + return IngestResult{}, err + } if err := requireCommittedRun(req.RepoRoot, out.Run); err != nil { return IngestResult{}, err } @@ -187,7 +201,7 @@ func Ingest(req IngestRequest) (IngestResult, error) { if err != nil { return IngestResult{}, err } - if err := refuseAuthored(out, ctx.Supplied.Dispositions, items); err != nil { + if err := refuseAuthored(out, supplied, items); err != nil { return IngestResult{}, err } @@ -368,6 +382,32 @@ func proveContext(req IngestRequest, out Output) (Context, Manifest, error) { return ctx, m, nil } +// proveSupplied authenticates the parked pair against the researcher's own +// text. The context and the manifest are parked in the local tier, where a +// scribe session granted tools can rewrite both and recompute every hash that +// binds them, so their agreement proves nothing about what the researcher +// wrote. The dispositions file the operator handed assemble is the one witness +// the session was never given: it is re-read here, scrubbed exactly as assemble +// scrubbed it, and the manifest's supplied hash and the context's supplied copy +// must both equal it. What the verbatim checks then read is that text. +func proveSupplied(req IngestRequest, ctx Context, m Manifest) (string, error) { + raw, err := fsutil.ReadGuarded(req.DispositionsPath, reading.MaxFileBytes) + if err != nil { + return "", fmt.Errorf("scribe: reading the supplied dispositions: %w", err) + } + supplied := scrub(req.RepoRoot, string(raw)) + if got := sha256Hex([]byte(supplied)); got != m.Supplied.DispositionsSHA256 { + return "", fmt.Errorf("scribe: the supplied dispositions hash to %s and the parked manifest records %s, "+ + "so the session was not assembled over this text, or the parked pair was rewritten; nothing is "+ + "written", got, echo(m.Supplied.DispositionsSHA256)) + } + if ctx.Supplied.Dispositions != supplied { + return "", fmt.Errorf("scribe: the context's copy of the supplied dispositions is not the researcher's " + + "text, so the parked pair was rewritten after assembly; nothing is written") + } + return supplied, nil +} + // refusePromoted refuses a run whose manifest is already beside it: the durable // tier is write-once, so a second session over the run would land its records // and then fail to promote. It is refused here, before anything lands. diff --git a/internal/core/scribe/ingest_test.go b/internal/core/scribe/ingest_test.go index da2a96e9c..7456d4fc6 100644 --- a/internal/core/scribe/ingest_test.go +++ b/internal/core/scribe/ingest_test.go @@ -20,6 +20,7 @@ const groundA = "the constraint the reading names is real and binds the verb as type session struct { fixture supplied string + dispPath string res AssembleResult } @@ -31,11 +32,12 @@ func assembleSession(t *testing.T, position string, n int, supplied string) sess for i, id := range f.items { supplied = strings.ReplaceAll(supplied, "{"+string(rune('0'+i))+"}", id) } - res, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: fixtureRun, DispositionsPath: supply(t, supplied)}) + dispPath := supply(t, supplied) + res, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: fixtureRun, DispositionsPath: dispPath}) if err != nil { t.Fatalf("Assemble: %v", err) } - return session{fixture: f, supplied: supplied, res: res} + return session{fixture: f, supplied: supplied, dispPath: dispPath, res: res} } // out is a payload skeleton for the session: the envelope filled, every list @@ -65,7 +67,7 @@ func (s session) writeRaw(t *testing.T, raw string) string { func (s session) ingest(t *testing.T, payloadPath string) (IngestResult, error) { t.Helper() - return Ingest(IngestRequest{RepoRoot: s.repo, ScribeJSONPath: payloadPath}) + return Ingest(IngestRequest{RepoRoot: s.repo, ScribeJSONPath: payloadPath, DispositionsPath: s.dispPath}) } func (s session) ledger(t *testing.T) string { @@ -524,3 +526,68 @@ func TestScribeIngestHoldsAnAdmissionToTheItemsLine(t *testing.T) { t.Fatal("a refused payload changed the ledger") } } + +// TestScribeIngestAuthenticatesTheParkedPair: the parked context and manifest +// sit where a scribe with tools can rewrite them, so neither is the witness to +// what the researcher wrote. A scribe that rewrites the context's supplied text +// to carry a ground of its own, and recomputes the manifest's hashes to match, +// is refused, because the ingest re-reads the researcher's own dispositions +// and holds the pair to them. +func TestScribeIngestAuthenticatesTheParkedPair(t *testing.T) { + authored := "a ground the scribe wrote and the researcher never did" + for _, tc := range []struct { + name string + rehashSupplied bool + }{ + {"the manifest's supplied hash left stale", false}, + {"the manifest's supplied hash recomputed", true}, + } { + t.Run(tc.name, func(t *testing.T) { + s := assembleSession(t, positionDetection, 1, "{0}: accepted — "+groundA+".\n") + before := s.ledger(t) + dir := filepath.Join(s.repo, filepath.FromSlash(DefaultRunDir), fixtureRun) + + ctx := s.res.Context + ctx.Supplied.Dispositions = s.items[0] + ": accepted — " + authored + ".\n" + ctxRaw, err := encode(ctx) + if err != nil { + t.Fatal(err) + } + m := s.res.Manifest + m.ContextSHA256 = sha(ctxRaw) + if tc.rehashSupplied { + m.Supplied.DispositionsSHA256 = sha([]byte(ctx.Supplied.Dispositions)) + } + mRaw, err := encode(m) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ContextFileName), ctxRaw, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ManifestFileName), mRaw, 0o644); err != nil { + t.Fatal(err) + } + + o := s.out() + o.ContextSHA256 = m.ContextSHA256 + o.Dispositions = []OutDisposition{{Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: authored}} + _, err = s.ingest(t, s.write(t, o)) + if err == nil || !strings.Contains(err.Error(), "dispositions") { + t.Fatalf("a rewritten parked pair carrying a scribe-authored ground was not refused: %v", err) + } + if s.ledger(t) != before { + t.Fatal("a rewritten parked pair let a write through") + } + }) + } + + // The researcher's text is required: without it there is no witness. + s := assembleSession(t, positionDetection, 1, "{0}: accepted — "+groundA+".\n") + o := s.out() + o.Dispositions = []OutDisposition{{Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}} + if _, err := Ingest(IngestRequest{RepoRoot: s.repo, ScribeJSONPath: s.write(t, o)}); err == nil || + !strings.Contains(err.Error(), "dispositions") { + t.Fatalf("an ingest with no supplied dispositions was not refused: %v", err) + } +} diff --git a/internal/surface/cli/scribe.go b/internal/surface/cli/scribe.go index 61e8c2ec7..bb77d6143 100644 --- a/internal/surface/cli/scribe.go +++ b/internal/surface/cli/scribe.go @@ -109,13 +109,16 @@ func newScribeCommand(asJSON *bool) *cobra.Command { assembleCmd.Flags().BoolVar(&dryRun, "dry-run", false, "write nothing; with --out the two artefacts still land in that directory") - var scribeJSON, contextPath string + var scribeJSON, contextPath, ingestDispositions string ingestCmd := &cobra.Command{ - Use: "ingest --scribe-json <path>", + Use: "ingest --scribe-json <path> --dispositions <path>", Short: "Validate a scribe session's output and write what it transcribed", Long: "Validate the JSON a scribe session returned and write its records through the capture verbs.\n\n" + "The context the session was handed is proven first: it must hash to its parked manifest, and\n" + - "the output must cite that hash. Then the output is refused if the scribe authored anything —\n" + + "the output must cite that hash. The pair is parked where a scribe session could rewrite it, so\n" + + "--dispositions names the researcher's own text again, the file assemble was handed: the\n" + + "manifest's supplied hash and the context's supplied copy must both equal it, and every check\n" + + "below reads it. Then the output is refused if the scribe authored anything —\n" + "a field outside the declared shapes, an item the supplied dispositions never name, a state or an\n" + "admission the item's own line of the supplied text does not carry, or a ground, exit condition\n" + "or surprise that does not stand verbatim in the supplied text once whitespace is folded — or if\n" + @@ -125,7 +128,7 @@ func newScribeCommand(asJSON *bool) *cobra.Command { "which apply their own redaction and refusals, the ordering gate included; the first refusal stops\n" + "the ingest and names what landed before it. Fidelity flags and refusals are reported and never\n" + "written. Once every write has landed the manifest is promoted beside the run, write-once.", - Example: " abcd scribe ingest --scribe-json ./scribe-output.json --json", + Example: " abcd scribe ingest --scribe-json ./scribe-output.json --dispositions ./dispositions.md --json", Args: func(_ *cobra.Command, args []string) error { if len(args) > 0 { return &exitError{Code: 2, Msg: "scribe ingest: this verb takes no positional argument; " + @@ -138,11 +141,17 @@ func newScribeCommand(asJSON *bool) *cobra.Command { return &exitError{Code: 2, Msg: "scribe ingest: --scribe-json <path> is required: the JSON " + "the scribe session returned"} } + if ingestDispositions == "" { + return &exitError{Code: 2, Msg: "scribe ingest: --dispositions <path> is required: the " + + "researcher's dispositions text assemble was handed, which every word the scribe carries is " + + "held to"} + } cwd := mustCwd() res, err := scribe.Ingest(scribe.IngestRequest{ - RepoRoot: captureRoot(cwd), - ScribeJSONPath: resolveAgainst(cwd, scribeJSON), - ContextPath: resolveAgainst(cwd, contextPath), + RepoRoot: captureRoot(cwd), + ScribeJSONPath: resolveAgainst(cwd, scribeJSON), + ContextPath: resolveAgainst(cwd, contextPath), + DispositionsPath: resolveAgainst(cwd, ingestDispositions), }) if err != nil { // A refusal after something landed discloses what landed, before it @@ -160,6 +169,8 @@ func newScribeCommand(asJSON *bool) *cobra.Command { }, } ingestCmd.Flags().StringVar(&scribeJSON, "scribe-json", "", "path to the JSON the scribe session returned") + ingestCmd.Flags().StringVar(&ingestDispositions, "dispositions", "", + "the researcher's dispositions text, the file assemble was handed") ingestCmd.Flags().StringVar(&contextPath, "context", "", "the context the session was handed, when assemble wrote it under --out\n"+ "(default: the local-tier scribe run directory of the output's run)") diff --git a/internal/surface/cli/scribe_surface_test.go b/internal/surface/cli/scribe_surface_test.go index fcb4d837f..733104451 100644 --- a/internal/surface/cli/scribe_surface_test.go +++ b/internal/surface/cli/scribe_surface_test.go @@ -26,7 +26,7 @@ import ( var scribeOperands = map[string][]string{ "abcd scribe": {}, "abcd scribe assemble": {"dispositions", "dry-run", "out", "run"}, - "abcd scribe ingest": {"context", "scribe-json"}, + "abcd scribe ingest": {"context", "dispositions", "scribe-json"}, } // TestScribeOperandsArePinned walks the registered tree and holds each scribe @@ -193,7 +193,7 @@ func TestScribeIngestRendersOnRefusal(t *testing.T) { } stdout.Reset() stderr.Reset() - code := Run([]string{"scribe", "ingest", "--scribe-json", p, "--json"}, &stdout, &stderr) + code := Run([]string{"scribe", "ingest", "--scribe-json", p, "--dispositions", disp, "--json"}, &stdout, &stderr) if code != 2 { t.Fatalf("a refused ingest exited %d, want 2\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) } From c4449c4eba11a3b425713a28de12c4452e9158b9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:47:53 +0100 Subject: [PATCH 51/78] =?UTF-8?q?chore:=20resolve=20iss-2609261036354146?= =?UTF-8?q?=20=E2=80=94=20the=20parked=20pair=20is=20held=20to=20the=20res?= =?UTF-8?q?earcher's=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261036354146 Assisted-by: Claude:claude-opus-5-5 --- ...gest-authenticates-nothing-about-the-parked-context.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261036354146-scribe-ingest-authenticates-nothing-about-the-parked-context.md (56%) diff --git a/.abcd/work/issues/open/iss-2609261036354146-scribe-ingest-authenticates-nothing-about-the-parked-context.md b/.abcd/work/issues/resolved/iss-2609261036354146-scribe-ingest-authenticates-nothing-about-the-parked-context.md similarity index 56% rename from .abcd/work/issues/open/iss-2609261036354146-scribe-ingest-authenticates-nothing-about-the-parked-context.md rename to .abcd/work/issues/resolved/iss-2609261036354146-scribe-ingest-authenticates-nothing-about-the-parked-context.md index 600cf5229..c0a379f8f 100644 --- a/.abcd/work/issues/open/iss-2609261036354146-scribe-ingest-authenticates-nothing-about-the-parked-context.md +++ b/.abcd/work/issues/resolved/iss-2609261036354146-scribe-ingest-authenticates-nothing-about-the-parked-context.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-scribe" origin: researcher-authored production_mode: hand-written found_at: "internal/core/scribe/ingest.go" +resolution: "scribe ingest requires --dispositions, the researcher's file assemble was handed; the manifest's supplied hash and the context's supplied copy must both equal it, and every authoring check reads it, so a rewritten parked pair is refused" +impact: internal +resolved_by: + commit: "21dce529" --- scribe ingest authenticates nothing about the parked context and manifest pair: a scribe that rewrites context.json's supplied dispositions and recomputes the manifest's context hash passes the verbatim check against text it wrote itself, while manifest.supplied.dispositions_sha256 is recorded and never read + +## Grounds + +- pursued: a parked context and manifest rewritten together to carry a scribe-authored ground are refused before anything is written; a rewritten pair that lands a record would show it wrong From a5f5a266662ed258f549a49a4847a391e68fd9f6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:48:45 +0100 Subject: [PATCH 52/78] fix(reading,lint): one derivation of a principle's statement for both readers The reading projection and the principle_claims lint read a principle's statement separately and disagreed three ways. The statement is now found once, by lint.FindPrincipleStatement, which both use: - A principle resolves by its **The rule.** paragraph alone. projectField took a `## The rule` heading first, so a heading-shaped principle sent the whole section, its Why and Bounds included. A principle with no labelled paragraph contributes no item, as the spec says, and a TYPED one is reported by principle_claims, because otherwise a reading would never receive it and no gate would say why. Both readers now refuse the heading as a statement, instead of each refusing half the file shape: refusing it in the assembler too would stop a whole reading over one untyped entry the lint does not judge. - The lint judges the H1 title the projection sends above the paragraph, so a title citing a record is reported by record-lint and not only refused by the assembler. - Links of every shape: the lint refuses inline, full and collapsed reference links, the `](target)` tail a bracketed label leaves, URI and email autolinks and bare http(s)/ftp/www URLs. The projection unwraps inline and reference links to their labels, and verifyPrincipleItem refuses a link with no label to keep. The exclusion row's Detail ("record handles and links in a principle") is accurate as written, so the rendered table and its digest stay put. projectField drops its general labelled-paragraph leg. The principle was its only intended reader. Every other kind resolves by heading and then frontmatter, as it did before the principles row, so a bold paragraph can no longer stand in for a missing heading of another kind. AssemblerVersionCore stays 1.11.0: the contract it names has not left this branch. Refs: iss-2609261039132350, iss-2609261039134673, iss-2609261039139464 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/23-reading.md | 10 +- .abcd/development/principles/README.md | 6 +- internal/core/lint/principles.go | 138 +++++++++++++-- internal/core/lint/principles_test.go | 51 ++++++ internal/core/reading/assemble.go | 13 +- internal/core/reading/include.go | 19 +- internal/core/reading/principle_test.go | 166 +++++++++++++++++- internal/core/reading/project.go | 65 ++++--- 8 files changed, 407 insertions(+), 61 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/23-reading.md b/.abcd/development/brief/04-surfaces/23-reading.md index 7496bc6b6..189506d78 100644 --- a/.abcd/development/brief/04-surfaces/23-reading.md +++ b/.abcd/development/brief/04-surfaces/23-reading.md @@ -107,9 +107,13 @@ entry with the signal by which a reader detects it. ([adr-2609021016270132](../../decisions/adrs/2609021016270132-the-principles-family-is-a-declared-record-store-whose-entri.md)). The principles family is admitted at the widening, entailment and detection positions and projected to one field: each principle's H1 title above its -`**The rule.**` paragraph, with every link unwrapped to its label. Its four claim -keys and its citations stay behind, and the floor asserts both; an assembly whose -principle item still carries a record handle is refused rather than stamped. The +`**The rule.**` paragraph, with every inline or reference-style link unwrapped to +its label. A `## The rule` heading is never read as the statement, and a principle +with no such paragraph contributes no item. Its four claim keys and its citations +stay behind, and the floor asserts both; an assembly whose principle item still +carries a record handle, a bare URL or an autolink is refused rather than stamped, +and record-lint's `principle_claims` judges a typed principle's title and +paragraph by the same reading first. The table admits the family, and a committed entry naming the `principle` kind is what hands it to a run; the committed entries name no such kind, so no reading receives the knowledge record until one does. diff --git a/.abcd/development/principles/README.md b/.abcd/development/principles/README.md index 8b32a09f7..2f0615969 100644 --- a/.abcd/development/principles/README.md +++ b/.abcd/development/principles/README.md @@ -54,8 +54,10 @@ A key considered and declined is the literal `null`; an absent key is a claim not carried. Population is forward-only: an entry carrying none of the four keys is counted by the record lint as untyped (`principle_untyped`, a warning), and nothing backfills one. An entry carrying any of them carries all four -(`principle_claims`, blocking), and its `**The rule.**` paragraph carries no -record handle and no link, because a reading receives the H1 title and that +(`principle_claims`, blocking), states its rule as a `**The rule.**` +paragraph rather than a `## The rule` heading, and carries no record handle and +no link of any shape (inline, reference-style, autolink or bare URL) in that +paragraph or its H1 title, because a reading receives the title and that paragraph and nothing else. Evidence naming a scope condition is read against the condition's standing disposition: falsified blocks (`principle_falsified`), and narrowed, untested or unresolvable is reported (`principle_inheritance`). diff --git a/internal/core/lint/principles.go b/internal/core/lint/principles.go index a1fe05ae6..016cffd32 100644 --- a/internal/core/lint/principles.go +++ b/internal/core/lint/principles.go @@ -103,11 +103,104 @@ var ( // principleEvidenceHandleRe is the record-handle half of evidence's grammar: // the families a principle distils, spelled lower case and unpadded. principleEvidenceHandleRe = regexp.MustCompile(`^(adr|itd|spc|iss|rdi)-([0-9]+)$`) - // statementLinkRe finds a markdown inline link, whose target is a citation - // however its label reads. - statementLinkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)]*)\)`) + // statementLinkRe finds a link of any shape a principle's statement can + // carry, because a link target is a citation however its label reads + // (iss-2609261039139464): an inline link, a full or collapsed reference + // link, the `](target)` tail an inline link with brackets in its label + // leaves, a URI or email autolink, and a bare URL of the shapes GFM links + // unmarked. A shortcut reference `[label]` is not among them: without its + // definition it is literal text, and its label is prose either way. + statementLinkRe = regexp.MustCompile(`\[[^\]]*\]\([^)]*\)` + + `|\[[^\]]*\]\[[^\]]*\]` + + `|\]\([^)]*\)` + + `|<[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>]*>` + + `|<[^\s<>@]+@[^\s<>@]+>` + + `|(?i:\b(?:https?|ftp)://|\bwww\.)[^\s<>]*`) + // statementLabelledLinkRe is the two link shapes that carry a label: an + // inline link and a full or collapsed reference link. + statementLabelledLinkRe = regexp.MustCompile(`\[([^\]]*)\](?:\([^)]*\)|\[[^\]]*\])`) + // principleTitleRe is an ATX H1; principleTitleCloseRe is its optional + // closing sequence, which is not part of the title. + principleTitleRe = regexp.MustCompile(`^#[ \t]+(.*)$`) + principleTitleCloseRe = regexp.MustCompile(`[ \t]+#+[ \t]*$`) ) +// PrincipleStatement is where a principle's statement sits in its document: +// the H1 title and the `**The rule.**` labelled paragraph, and nothing else. +// A `## The rule` heading is NOT a statement (iss-2609261039132350): a +// principle has one heading, its H1, and a section would carry everything +// under it, the reasons and the bounds included. +// +// It is the one derivation of the statement. The reading assembler projects +// it and principle_claims judges it, and two readers deriving it separately +// are how a gate came to judge a paragraph while the projection sent a title +// above it (iss-2609261039134673). +type PrincipleStatement struct { + // Title is the H1 title with any closing hashes removed, and TitleLine its + // 0-based line; TitleLine is -1 when the document carries no H1. + Title string + TitleLine int + // Start and End bound the paragraph's lines, [Start, End), 0-based, the + // label still on the first. + Start, End int +} + +// FindPrincipleStatement locates a principle's statement in its lines (the +// whole document, frontmatter included). ok is false when the document carries +// no live `**The rule.**` paragraph, whatever headings it has. +func FindPrincipleStatement(lines []string) (PrincipleStatement, bool) { + body := principleBodyStart(lines) + start, end, ok := mdrecord.LabelledParagraph(lines[body:], PrincipleStatementLabel) + if !ok { + return PrincipleStatement{}, false + } + st := PrincipleStatement{TitleLine: -1, Start: body + start, End: body + end} + mask := mdrecord.Mask(lines[body:]) + for i, ln := range lines[body:] { + if i < len(mask) && mask[i] != 0 { + continue + } + m := principleTitleRe.FindStringSubmatch(strings.TrimRight(ln, "\r")) + if m == nil { + continue + } + if title := strings.TrimSpace(principleTitleCloseRe.ReplaceAllString(m[1], "")); title != "" && title != "#" { + st.Title, st.TitleLine = title, body+i + break + } + } + return st, true +} + +// principleBodyStart is the first line after the leading frontmatter block, 0 +// when there is none: a YAML comment is a `#` line, and it is not a title. +func principleBodyStart(lines []string) int { + if len(lines) == 0 || !frontmatter.IsDelimiter(frontmatter.TrimBOM(lines[0])) { + return 0 + } + for i := 1; i < len(lines); i++ { + if !strings.HasPrefix(lines[i], " ") && !strings.HasPrefix(lines[i], "\t") && frontmatter.IsDelimiter(lines[i]) { + return i + 1 + } + } + return 0 +} + +// PrincipleLinkIn returns the first link of any shape the text carries, and +// whether it carries one: the one answer both readers give to "does this +// statement link?". +func PrincipleLinkIn(text string) (string, bool) { + m := statementLinkRe.FindString(text) + return m, m != "" +} + +// UnwrapPrincipleLinks replaces every labelled link with its label, on the +// renderedTexts precedent: the target is a citation and the label is prose. +// What it cannot unwrap, a link with no label, it leaves for PrincipleLinkIn. +func UnwrapPrincipleLinks(text string) string { + return statementLabelledLinkRe.ReplaceAllString(text, "$1") +} + // principleRules names the four rules in dispatch order. var principleRules = []string{rulePrincipleUntyped, rulePrincipleClaims, rulePrincipleInheritance, rulePrincipleFalsified} @@ -292,17 +385,36 @@ func (p principleCheck) judge(repoRoot string, r schemaRecord) ([]Finding, error if err != nil { return nil, err } + // It is judged over exactly what the projection sends, the H1 title and + // the paragraph, found by the one derivation both readers share. lines := strings.Split(string(content), "\n") - if start, end, ok := mdrecord.LabelledParagraph(lines, PrincipleStatementLabel); ok { - statement := strings.Join(lines[start:end], "\n") - if m := statementLinkRe.FindStringSubmatch(statement); m != nil { - claim(start+1, "the **"+PrincipleStatementLabel+".** paragraph carries a link to '"+m[1]+"'; the "+ - "statement travels to a reading as knowledge and its citations stay behind as genealogy, so the "+ - "link belongs in `evidence` or below the statement") - } else if id, ok := recordid.HandleInText(statement); ok { - claim(start+1, "the **"+PrincipleStatementLabel+".** paragraph carries the record handle '"+id+"'; the "+ - "statement travels to a reading as knowledge and its citations stay behind as genealogy, so the "+ - "handle belongs in `evidence` or below the statement") + st, ok := FindPrincipleStatement(lines) + if !ok { + claim(1, "carries no **"+PrincipleStatementLabel+".** paragraph, so a reading receives no statement of it; "+ + "the statement is the paragraph opening with that label, and a `## "+PrincipleStatementLabel+ + "` heading is not read as one, because a section carries the reasons and bounds under it") + } else { + parts := []struct { + what string + line int + text string + }{ + {"title", st.TitleLine + 1, st.Title}, + {"**" + PrincipleStatementLabel + ".** paragraph", st.Start + 1, strings.Join(lines[st.Start:st.End], "\n")}, + } + for _, part := range parts { + if part.text == "" { + continue + } + if m, ok := PrincipleLinkIn(part.text); ok { + claim(part.line, "the "+part.what+" carries the link '"+m+"'; the statement travels to a reading as "+ + "knowledge and its citations stay behind as genealogy, so the link belongs in `evidence` or "+ + "below the statement") + } else if id, ok := recordid.HandleInText(part.text); ok { + claim(part.line, "the "+part.what+" carries the record handle '"+id+"'; the statement travels to a "+ + "reading as knowledge and its citations stay behind as genealogy, so the handle belongs in "+ + "`evidence` or below the statement") + } } } diff --git a/internal/core/lint/principles_test.go b/internal/core/lint/principles_test.go index 7e6b40d91..efa070f8a 100644 --- a/internal/core/lint/principles_test.go +++ b/internal/core/lint/principles_test.go @@ -305,6 +305,16 @@ func TestPrincipleStatementMayNotCite(t *testing.T) { "record handle": "Fix the class, as adr-1 ruled.", "markdown link": "Fix the class, as [the ruling](../decisions/adrs/0001-a.md) says.", "condition": "Fix the class while " + prnCondA + " holds.", + // Every link shape, not the inline one alone (iss-2609261039139464): a + // URL is a citation whatever markup carries it, and a reference-style + // link is a link whose target sits elsewhere. + "bare URL": "Fix the class, as https://example.com/LEAKURL says.", + "bare www URL": "Fix the class, as www.example.com/LEAKURL says.", + "autolink": "Fix the class, as <https://example.com/LEAKAUTO> says.", + "email autolink": "Fix the class, as <someone@example.com> says.", + "reference link": "Fix the class, as [LEAKREFLABEL][ruling] says.", + "collapsed reference": "Fix the class, as [the ruling][] says.", + "nested-bracket link": "Fix the class, as [the [first] ruling](../decisions/adrs/0001-a.md) says.", } { t.Run(name, func(t *testing.T) { root := t.TempDir() @@ -318,6 +328,47 @@ func TestPrincipleStatementMayNotCite(t *testing.T) { } } +// TestPrincipleTitleMayNotCite: the statement a reading receives is the H1 +// title AND the paragraph, so the lint judges the title the projection sends +// above the paragraph, and a citation there is refused by record-lint rather +// than only by the assembler (iss-2609261039134673). +func TestPrincipleTitleMayNotCite(t *testing.T) { + for name, title := range map[string]string{ + "record handle": "A principle citing itd-79", + "markdown link": "A principle after [the ruling](../decisions/adrs/0001-a.md)", + "bare URL": "A principle after https://example.com/LEAKURL", + } { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + doc := strings.Replace(typedPrinciple("p", "causal", `"abcd lint"`, `"One against another."`, "[adr-1]", + "Fix the class, not the instance."), "# A principle\n", "# "+title+"\n", 1) + writeFile(t, root, prnDir+"/p.md", doc) + writeFile(t, root, "rec/decisions/adrs/0001-a.md", "---\nid: adr-1\n---\n# ADR-1\n") + fs := lintPrinciples(t, root) + if !findingWith(fs, filepath.Join(prnDir, "p.md"), rulePrincipleClaims, "title") { + t.Errorf("%s in the title is not refused: %v", name, rulesOf(fs, filepath.Join(prnDir, "p.md"))) + } + }) + } +} + +// TestHeadingShapedPrincipleHasNoStatement: the statement is the labelled +// paragraph and nothing else, in both readers (iss-2609261039132350). A typed +// principle that writes it as a `## The rule` heading carries no statement the +// projection can send, and principle_claims says so rather than passing a +// principle no reading will ever receive. +func TestHeadingShapedPrincipleHasNoStatement(t *testing.T) { + root := t.TempDir() + doc := strings.Replace(typedPrinciple("p", "causal", `"abcd lint"`, `"One against another."`, "[adr-1]", + "Fix the class, not the instance."), "**The rule.** ", "## The rule\n\n", 1) + writeFile(t, root, prnDir+"/p.md", doc) + writeFile(t, root, "rec/decisions/adrs/0001-a.md", "---\nid: adr-1\n---\n# ADR-1\n") + fs := lintPrinciples(t, root) + if !findingWith(fs, filepath.Join(prnDir, "p.md"), rulePrincipleClaims, "no **The rule.** paragraph") { + t.Errorf("a heading-shaped statement is not reported: %v", rulesOf(fs, filepath.Join(prnDir, "p.md"))) + } +} + // inheritanceCorpus writes one typed principle resting on prnCondA, and one // shipped intent carrying that condition with the given audit block. func inheritanceCorpus(t *testing.T, audit string) string { diff --git a/internal/core/reading/assemble.go b/internal/core/reading/assemble.go index 89ba60722..9658ded3f 100644 --- a/internal/core/reading/assemble.go +++ b/internal/core/reading/assemble.go @@ -1172,7 +1172,7 @@ func collect(repoRoot string, position Position, candidateRun string) ([]candida continue } for i, field := range row.Fields { - text, ok, err := projectField(rel, doc, field) + text, ok, err := projectField(rel, doc, field, row.Kind) if err != nil { return nil, err } @@ -1199,8 +1199,9 @@ func collect(repoRoot string, position Position, candidateRun string) ([]candida // verifyPrincipleItem refuses a principle item that still carries a citation // after projection. The manifest asserts that a principle travels without its // record handles and links (the floor's citation entry); the projection keeps -// only the statement and unwraps a link to its label, and this is what makes -// the assertion checked rather than trusted — a statement that names a record +// only the statement and unwraps a labelled link to its label, which leaves a +// link with no label (a bare URL, an autolink) to refuse here, and this is what +// makes the assertion checked rather than trusted — a statement that names a record // in its own words would otherwise ride into the bundle under a manifest saying // it had not. func verifyPrincipleItem(c candidate) error { @@ -1212,9 +1213,11 @@ func verifyPrincipleItem(c candidate) error { "statement, and the manifest asserts a principle travels without its citations; move the "+ "handle into the principle's evidence or below its statement", c.path, id) } - if m := mdLinkRe.FindString(c.text); m != "" { + if m, ok := lint.PrincipleLinkIn(c.text); ok { return fmt.Errorf("reading: the principle %s carries the link %s in its projected statement, "+ - "and the manifest asserts a principle travels without its citations", c.path, m) + "and the manifest asserts a principle travels without its citations; a link with no label "+ + "to keep, a bare URL or an autolink, belongs in the principle's evidence or below its statement", + c.path, m) } return nil } diff --git a/internal/core/reading/include.go b/internal/core/reading/include.go index e9c8ca4a0..3f4f76efc 100644 --- a/internal/core/reading/include.go +++ b/internal/core/reading/include.go @@ -19,6 +19,7 @@ import ( "github.com/intentdriven/abcd/internal/core/capture" "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/lint" ) // AssemblerVersionCore is the hand-set semver of the assembly contract: the @@ -344,10 +345,12 @@ var coldPositions = []Position{PositionWidening, PositionEntailment, PositionDet const PrincipleSource = ".abcd/development/principles" // PrincipleField is the one field a principle travels as: its statement, the -// labelled paragraph `**The rule.**` with the H1 title above it (projectField's -// labelled-paragraph resolution). Everything after it — the reasons, the -// bounds, the promotion rung — and every frontmatter key stays behind. -const PrincipleField = "The rule" +// labelled paragraph `**The rule.**` with the H1 title above it +// (projectPrincipleStatement, over the derivation principle_claims shares, so +// the label is the lint's own constant). Everything after it — the reasons, +// the bounds, the promotion rung — and every frontmatter key stays behind, and +// a `## The rule` heading is never read as the statement. +const PrincipleField = lint.PrincipleStatementLabel // CandidateSource is the ledger directory the candidate row reaches: the working // tier's readings store, one directory per run. It is the leaf bucket the @@ -670,9 +673,11 @@ var Exclusions = []Exclusion{ }, {Rule: "absent from the positive walk", Signal: "file", Detail: ".abcd/work/DECISIONS.md"}, // A principle's citations. The projection keeps the statement and unwraps a - // link to its label, and verifyPrincipleItem refuses the assembly if a - // record handle survives into a principle item, so this is an assertion the - // assembler checks rather than a disclosure a reader trusts. + // labelled link, inline or reference-style, to its label, and + // verifyPrincipleItem refuses the assembly if a record handle or a link of + // any shape (a bare URL, an autolink) survives into a principle item, so this + // is an assertion the assembler checks rather than a disclosure a reader + // trusts. {Rule: "the statement is knowledge and the citations are genealogy", Signal: "citation", Detail: "record handles and links in a principle"}, // The local ledger tier. It was excluded from the first day and asserted by diff --git a/internal/core/reading/principle_test.go b/internal/core/reading/principle_test.go index 28ec9ffaa..1886228c3 100644 --- a/internal/core/reading/principle_test.go +++ b/internal/core/reading/principle_test.go @@ -4,6 +4,8 @@ import ( "fmt" "strings" "testing" + + "github.com/intentdriven/abcd/internal/core/lint" ) // The knowledge record as a read object (spc-2609020626042471): a principle @@ -47,7 +49,7 @@ func principleFixture(t *testing.T, rule string) string { // TestLabelledParagraphResolves: a field naming a label resolves as the first // paragraph opening with it, label removed, and nothing after it. func TestLabelledParagraphResolves(t *testing.T) { - text, ok, err := projectField(principleRel, principleDoc(defaultRule), "The rule") + text, ok, err := projectField(principleRel, principleDoc(defaultRule), "The rule", KindPrinciple) if err != nil || !ok { t.Fatalf("projectField(The rule) = %q, %v, %v", text, ok, err) } @@ -60,7 +62,7 @@ func TestLabelledParagraphResolves(t *testing.T) { } } // A document without the paragraph contributes no item. - if _, ok, _ := projectField(principleRel, "# A principle\n\nProse only.\n", "The rule"); ok { + if _, ok, _ := projectField(principleRel, "# A principle\n\nProse only.\n", "The rule", KindPrinciple); ok { t.Error("a document with no labelled paragraph projected one") } } @@ -68,7 +70,7 @@ func TestLabelledParagraphResolves(t *testing.T) { // TestLabelledParagraphCarriesTheTitle: a rule without its name is not readable // cold, so the H1 title is placed above the statement. func TestLabelledParagraphCarriesTheTitle(t *testing.T) { - text, _, _ := projectField(principleRel, principleDoc(defaultRule), "The rule") + text, _, _ := projectField(principleRel, principleDoc(defaultRule), "The rule", KindPrinciple) if !strings.HasPrefix(text, "# Fix the detector\n\n"+principleStatement) { t.Errorf("the projection does not open with the title above the statement: %q", text) } @@ -77,7 +79,7 @@ func TestLabelledParagraphCarriesTheTitle(t *testing.T) { // TestLinksUnwrapInTheStatement: a link target is a citation and the label is // prose, so the target stays behind and the label travels. func TestLinksUnwrapInTheStatement(t *testing.T) { - text, _, _ := projectField(principleRel, principleDoc(defaultRule), "The rule") + text, _, _ := projectField(principleRel, principleDoc(defaultRule), "The rule", KindPrinciple) if strings.Contains(text, sentinelPrincipleLink) || strings.Contains(text, "](") { t.Errorf("the link target travelled: %q", text) } @@ -258,3 +260,159 @@ func TestManifestAtTheOldSchemaVersionIsRefused(t *testing.T) { t.Error("a manifest at schema version 10 decoded") } } + +// typedPrincipleWith renders a typed principle whose body after the +// frontmatter is given whole, for the shapes principleDoc does not write. +func typedPrincipleWith(body string) string { + return "---\nid: prn-fix-the-detector\nclaim_type: causal\nreference: \"abcd lint\"\n" + + "comparison: \"Hand fixes against a detector.\"\nevidence: [itd-181]\n---\n\n" + body +} + +// principleFixtureWith commits one principle document as given. +func principleFixtureWith(t *testing.T, doc string) string { + t.Helper() + root := fixtureRepo(t) + writeFile(t, root, principleRel, doc) + gitCommitAll(t, root) + return root +} + +// TestHeadingShapedStatementNeverTravels is the LEAKHEAD probe +// (iss-2609261039132350): a principle's statement is the labelled paragraph +// and nothing else, so a `## The rule` SECTION is never a statement. Written +// alone it contributes no item; written above the paragraph, the paragraph is +// what travels. Either way the section's Why and Bounds stay behind. +func TestHeadingShapedStatementNeverTravels(t *testing.T) { + tail := "\n\n**Why.** LEAKHEAD-WHY.\n\n**Bounds.** LEAKHEAD-BOUNDS.\n" + for name, tc := range map[string]struct { + body string + wantItem bool + }{ + "heading alone": {"# Fix the detector\n\n## The rule\n\n" + principleStatement + "." + tail, false}, + "heading above the paragraph": {"# Fix the detector\n\n## The rule\n\n**The rule.** " + principleStatement + "." + tail, true}, + } { + t.Run(name, func(t *testing.T) { + root := principleFixtureWith(t, typedPrincipleWith(tc.body)) + for _, p := range []Position{PositionWidening, PositionEntailment, PositionDetection} { + res := assembleFixture(t, root, p) + text := bundleText(res.Bundle) + for _, gone := range []string{"LEAKHEAD-WHY", "LEAKHEAD-BOUNDS", "**Why.**"} { + if strings.Contains(text, gone) { + t.Errorf("at %s the bundle carries %q from the heading's section", p, gone) + } + } + n := 0 + for _, it := range res.Manifest.Items { + if it.Path == principleRel { + n++ + } + } + if tc.wantItem != (n == 1) || n > 1 { + t.Errorf("at %s the principle travelled as %d item(s), want item=%v", p, n, tc.wantItem) + } + if tc.wantItem && !strings.Contains(text, "# Fix the detector\n\n"+principleStatement) { + t.Errorf("at %s the labelled paragraph did not travel under its title: %q", p, text) + } + } + }) + } +} + +// TestReferenceLinksUnwrapInTheStatement (LEAKREFLABEL, iss-2609261039139464): +// a reference-style link is a link whose target sits in a definition below, +// so it is unwrapped to its label exactly as an inline link is, and neither +// its brackets nor its definition travel. +func TestReferenceLinksUnwrapInTheStatement(t *testing.T) { + root := principleFixtureWith(t, typedPrincipleWith("# Fix the detector\n\n**The rule.** "+principleStatement+ + ", as [the LEAKREFLABEL ruling][ruling] and [the second ruling][] say.\n\n"+ + "[ruling]: https://example.com/LEAKREFTARGET\n[the second ruling]: https://example.com/LEAKREFTARGET\n")) + text := bundleText(assembleFixture(t, root, PositionDetection).Bundle) + if !strings.Contains(text, "as the LEAKREFLABEL ruling and the second ruling say.") { + t.Errorf("the reference links' labels did not travel as prose: %q", text) + } + for _, gone := range []string{"][", "LEAKREFTARGET", "[ruling]"} { + if strings.Contains(text, gone) { + t.Errorf("the bundle carries %q of a reference link", gone) + } + } +} + +// TestUnlabelledLinkInTheStatementRefuses (LEAKURL, LEAKAUTO, +// iss-2609261039139464): a bare URL or an autolink has no label to keep, so +// there is no prose to unwrap it to; the projection cannot send the statement +// without its citation, and the assembly refuses and names both. +func TestUnlabelledLinkInTheStatementRefuses(t *testing.T) { + for name, link := range map[string]string{ + "bare URL": "https://example.com/LEAKURL", + "www URL": "www.example.com/LEAKURL", + "autolink": "<https://example.com/LEAKAUTO>", + "email autolink": "<someone@example.com>", + } { + t.Run(name, func(t *testing.T) { + root := principleFixtureWith(t, typedPrincipleWith("# Fix the detector\n\n**The rule.** "+ + principleStatement+", as "+link+" says.\n")) + _, err := Assemble(AssembleRequest{RepoRoot: root, Position: PositionDetection, Target: "HEAD", DryRun: true}) + if err == nil { + t.Fatalf("a principle whose statement carries %s assembled", link) + } + for _, want := range []string{principleRel, strings.Trim(link, "<>")} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } + }) + } +} + +// TestPrincipleReadersAgreeOnTheStatement: the assembler and the +// principle_claims lint are two readers of one statement, and the lint is the +// gate that runs first, so every typed principle the assembler refuses, or +// sends without a statement, is one record-lint reported (iss-2609261039134673, +// iss-2609261039132350). The reverse is not asserted: an inline or reference +// link is refused by the lint and unwrapped by the projection, the stricter +// reader sitting in front. +func TestPrincipleReadersAgreeOnTheStatement(t *testing.T) { + cfg := lint.Config{Rules: map[string]lint.RuleConfig{ + "record_schema": {Enabled: true, Severity: "blocker", RecordStores: map[string]string{ + "prn": ".abcd/development/principles", "itd": ".abcd/development/intents"}}, + "principle_claims": {Enabled: true, Severity: "blocker"}, + }} + for name, body := range map[string]string{ + "title handle": "# Fix the detector after itd-79\n\n**The rule.** " + principleStatement + ".\n", + "title link": "# Fix the [detector](https://example.com/x)\n\n**The rule.** " + principleStatement + ".\n", + "statement URL": "# Fix the detector\n\n**The rule.** " + principleStatement + ", per https://example.com/x.\n", + "statement handle": "# Fix the detector\n\n**The rule.** " + principleStatement + ", per adr-1.\n", + "heading-shaped": "# Fix the detector\n\n## The rule\n\n" + principleStatement + ".\n", + "clean": "# Fix the detector\n\n**The rule.** " + principleStatement + ".\n", + } { + t.Run(name, func(t *testing.T) { + root := principleFixtureWith(t, typedPrincipleWith(body)) + res, err := Assemble(AssembleRequest{RepoRoot: root, Position: PositionDetection, Target: "HEAD", DryRun: true}) + missing := err == nil + if missing { + for _, it := range res.Manifest.Items { + if it.Path == principleRel { + missing = false + } + } + } + fs, lerr := lint.Lint(cfg, root) + if lerr != nil { + t.Fatal(lerr) + } + reported := false + for _, f := range fs { + if f.File == principleRel && f.RuleID == "principle_claims" { + reported = true + } + } + if (err != nil || missing) && !reported { + t.Errorf("the assembler refused or dropped the statement (err=%v, dropped=%v) and record-lint "+ + "reported nothing", err, missing) + } + if name == "clean" && (err != nil || missing || reported) { + t.Errorf("a clean principle: err=%v dropped=%v reported=%v", err, missing, reported) + } + }) + } +} diff --git a/internal/core/reading/project.go b/internal/core/reading/project.go index c5b37f4c2..6fb992a9e 100644 --- a/internal/core/reading/project.go +++ b/internal/core/reading/project.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/intentdriven/abcd/internal/core/frontmatter" - "github.com/intentdriven/abcd/internal/core/mdrecord" + "github.com/intentdriven/abcd/internal/core/lint" "github.com/intentdriven/abcd/internal/core/site" ) @@ -1322,7 +1322,21 @@ func sectionSpan(sections []site.Section, i, total int) (int, int) { // projectField extracts one named field from a record's text. Only a record is // ever projected, and a record is markdown, so the same scope holds here. -func projectField(rel, doc, field string) (string, bool, error) { +// +// A principle's field is its statement and resolves by +// projectPrincipleStatement alone. Every other kind's field resolves as a +// heading section and otherwise as a frontmatter key. The principle takes no heading leg because a section +// carries everything under it: a `## The rule` heading would send the reasons +// and the bounds under a manifest naming the statement (iss-2609261039132350). +func projectField(rel, doc, field string, kind Kind) (string, bool, error) { + if kind == KindPrinciple { + if field != lint.PrincipleStatementLabel { + return "", false, fmt.Errorf("reading: projecting %s from %s: a principle projects one field, %q", + field, rel, lint.PrincipleStatementLabel) + } + text, ok := projectPrincipleStatement(doc) + return text, ok, nil + } body, offset := site.StripFrontmatter(doc) sections, err := site.Sections(rel, body, offset) if err != nil { @@ -1336,9 +1350,6 @@ func projectField(rel, doc, field string) (string, bool, error) { start, end := sectionSpan(sections, i, len(lines)) return trimBlankEdges(lines[min(start+1, len(lines)):min(end, len(lines))]), true, nil } - if text, ok := labelledParagraph(sections, lines, field); ok { - return text, true, nil - } fields := frontmatter.Fields(strings.Split(doc, "\n")) if f, ok := fields[field]; ok && !frontmatter.IsNull(f.Value) { return f.Value, true, nil @@ -1346,34 +1357,34 @@ func projectField(rel, doc, field string) (string, bool, error) { return "", false, nil } -// labelledParagraph resolves a field as the first live paragraph opening with -// the field in bold, `**<field>.**`, taken to the next blank line -// (mdrecord.LabelledParagraph, the one reading the principles lint shares). +// projectPrincipleStatement projects a principle's statement +// (spc-2609020626042471): the labelled paragraph `**The rule.**` taken to the +// next blank line, found by lint.FindPrincipleStatement, the one derivation +// principle_claims judges too, so the gate and the projection read the same +// text (iss-2609261039134673). An entry with no such paragraph contributes no +// item. // -// It is how a principle's statement is found (spc-2609020626042471): a -// principle carries one heading, its H1, and a body of labelled paragraphs, -// so its statement is a paragraph and not a section. Two things are done to -// what it finds. The label is removed and the document's H1 title is placed -// above the paragraph, because a rule without its name is not readable cold. -// And every inline link is unwrapped to its label on the renderedTexts -// precedent: a link target is a citation, the label is prose, and the -// statement travels as knowledge while its citations stay behind. -func labelledParagraph(sections []site.Section, lines []string, field string) (string, bool) { - start, end, ok := mdrecord.LabelledParagraph(lines, field) +// Two things are done to what it finds. The label is removed and the H1 title +// is placed above the paragraph, because a rule without its name is not +// readable cold. And every labelled link, inline or reference-style, is +// unwrapped to its label on the renderedTexts precedent: a link target is a +// citation, the label is prose, and the statement travels as knowledge while +// its citations stay behind. A link with no label to keep, a bare URL or an +// autolink, is left for verifyPrincipleItem to refuse (iss-2609261039139464). +func projectPrincipleStatement(doc string) (string, bool) { + lines := strings.Split(doc, "\n") + st, ok := lint.FindPrincipleStatement(lines) if !ok { return "", false } - para := make([]string, 0, end-start) - for _, ln := range lines[start:end] { + para := make([]string, 0, st.End-st.Start) + for _, ln := range lines[st.Start:st.End] { para = append(para, strings.TrimRight(ln, "\r")) } - para[0] = strings.TrimLeft(strings.TrimPrefix(para[0], "**"+field+".**"), " \t") - body := strings.TrimSpace(mdLinkRe.ReplaceAllString(strings.Join(para, "\n"), "$1")) - for _, sec := range sections { - if sec.Level == 1 && strings.TrimSpace(sec.Title) != "" { - title := mdLinkRe.ReplaceAllString(normaliseHeadingTitle(sec.Title), "$1") - return "# " + title + "\n\n" + body, true - } + para[0] = strings.TrimLeft(strings.TrimPrefix(para[0], "**"+lint.PrincipleStatementLabel+".**"), " \t") + body := strings.TrimSpace(lint.UnwrapPrincipleLinks(strings.Join(para, "\n"))) + if st.Title != "" { + return "# " + lint.UnwrapPrincipleLinks(st.Title) + "\n\n" + body, true } return body, true } From 4e8b7f70397d53ac2f94a44ada41cbe34e1c9c34 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:49:01 +0100 Subject: [PATCH 53/78] chore: resolve review-principles' three findings on the principle readers Resolves: iss-2609261039132350 Resolves: iss-2609261039134673 Resolves: iss-2609261039139464 Assisted-by: Claude:claude-opus-5-5 --- ...ple-whose-statement-is-written-as-an-h2-heading-the.md | 8 ++++++++ ...ple-claims-judges-only-the-the-rule-paragraph-while.md | 8 ++++++++ ...le-statement-s-citation-check-reads-links-as-inline.md | 8 ++++++++ 3 files changed, 24 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261039132350-a-principle-whose-statement-is-written-as-an-h2-heading-the.md (54%) rename .abcd/work/issues/{open => resolved}/iss-2609261039134673-principle-claims-judges-only-the-the-rule-paragraph-while.md (55%) rename .abcd/work/issues/{open => resolved}/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md (52%) diff --git a/.abcd/work/issues/open/iss-2609261039132350-a-principle-whose-statement-is-written-as-an-h2-heading-the.md b/.abcd/work/issues/resolved/iss-2609261039132350-a-principle-whose-statement-is-written-as-an-h2-heading-the.md similarity index 54% rename from .abcd/work/issues/open/iss-2609261039132350-a-principle-whose-statement-is-written-as-an-h2-heading-the.md rename to .abcd/work/issues/resolved/iss-2609261039132350-a-principle-whose-statement-is-written-as-an-h2-heading-the.md index f68d4b6c3..4b3150c21 100644 --- a/.abcd/work/issues/open/iss-2609261039132350-a-principle-whose-statement-is-written-as-an-h2-heading-the.md +++ b/.abcd/work/issues/resolved/iss-2609261039132350-a-principle-whose-statement-is-written-as-an-h2-heading-the.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-principles" origin: researcher-authored production_mode: hand-written found_at: "internal/core/reading/project.go" +resolution: "A principle's statement resolves by its **The rule.** paragraph alone, through lint.FindPrincipleStatement shared with principle_claims; a ## The rule section never travels, and a typed heading-shaped principle is reported by principle_claims." +impact: fix +resolved_by: + commit: "a5f5a266662ed258f549a49a4847a391e68fd9f6" --- A principle whose statement is written as an H2 heading (## The rule) is projected by projectField's heading leg before the labelled-paragraph leg, so the whole section travels to a reading, its Why and Bounds included, under a manifest naming the field The rule; the lint judges only the labelled paragraph, so no gate refuses the shape and the floor's statement-alone promise breaks on a file shape. + +## Grounds + +- pursued: the LEAKHEAD probe (TestHeadingShapedStatementNeverTravels) carries no Why or Bounds at any cold position and TestHeadingShapedPrincipleHasNoStatement reports the typed shape; a bundle holding LEAKHEAD-WHY, or a typed heading-shaped principle passing record-lint, would show it wrong diff --git a/.abcd/work/issues/open/iss-2609261039134673-principle-claims-judges-only-the-the-rule-paragraph-while.md b/.abcd/work/issues/resolved/iss-2609261039134673-principle-claims-judges-only-the-the-rule-paragraph-while.md similarity index 55% rename from .abcd/work/issues/open/iss-2609261039134673-principle-claims-judges-only-the-the-rule-paragraph-while.md rename to .abcd/work/issues/resolved/iss-2609261039134673-principle-claims-judges-only-the-the-rule-paragraph-while.md index 5fdfcc341..f2647f339 100644 --- a/.abcd/work/issues/open/iss-2609261039134673-principle-claims-judges-only-the-the-rule-paragraph-while.md +++ b/.abcd/work/issues/resolved/iss-2609261039134673-principle-claims-judges-only-the-the-rule-paragraph-while.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-principles" origin: researcher-authored production_mode: hand-written found_at: "internal/core/lint/principles.go" +resolution: "principle_claims judges the H1 title as well as the paragraph, over the same derivation the projection uses, so a title citing a record is reported by record-lint." +impact: fix +resolved_by: + commit: "a5f5a266662ed258f549a49a4847a391e68fd9f6" --- principle_claims judges only the **The rule.** paragraph while the projection sends the H1 title above it and verifyPrincipleItem scans both, so a typed principle whose title cites a record (# Title citing itd-79) passes record-lint and then refuses the whole reading assembly: the two readers of a principle's statement disagree about what it is. + +## Grounds + +- pursued: TestPrincipleTitleMayNotCite reports a handle, a link and a URL in the title, and TestPrincipleReadersAgreeOnTheStatement finds no typed principle the assembler refuses or drops that record-lint passes; a title citation passing the lint while the assembly refuses would show it wrong diff --git a/.abcd/work/issues/open/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md b/.abcd/work/issues/resolved/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md similarity index 52% rename from .abcd/work/issues/open/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md rename to .abcd/work/issues/resolved/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md index d0eb7f4b5..245873703 100644 --- a/.abcd/work/issues/open/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md +++ b/.abcd/work/issues/resolved/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-principles" origin: researcher-authored production_mode: hand-written found_at: "internal/core/reading/project.go" +resolution: "Both readers widen to every link shape: the lint refuses inline, reference, autolink and bare-URL links in the statement; the projection unwraps labelled links and verifyPrincipleItem refuses one with no label." +impact: fix +resolved_by: + commit: "a5f5a266662ed258f549a49a4847a391e68fd9f6" --- A principle statement's citation check reads links as inline [label](target) only: a bare URL, an autolink <https://...> and a reference-style [label][ref] travel into the bundle raw and the lint is silent on all three, while the manifest's exclusion row asserts record handles and links in a principle stay behind. + +## Grounds + +- pursued: TestPrincipleStatementMayNotCite covers bare, www, autolink, email, reference, collapsed and nested-bracket links; TestReferenceLinksUnwrapInTheStatement and TestUnlabelledLinkInTheStatementRefuses hold LEAKREFLABEL, LEAKURL and LEAKAUTO; a URL or a ][ reaching a bundle would show it wrong From 68c58b8fe792b2c72aeadb01f2885270288e5b7c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:49:01 +0100 Subject: [PATCH 54/78] fix(scribe): promote the manifest only when the ingest landed a record A payload of outstanding items or refusals alone wrote no record and still promoted the write-once manifest beside the run, so the run was locked against the session that answers it later. The manifest now promotes only when at least one record landed; an ingest that lands nothing leaves it parked and the run open. The chapter states the one-landing-session-per-run rule as the departure from the spec it is, and discloses the residue: promotion follows what this ingest landed, so a rerun that lands nothing after a partial ingest promotes nothing. Refs: iss-2609261036355918 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/31-scribe.md | 20 ++++++++- commands/scribe.md | 7 +++- docs/reference/cli/commands.md | 3 +- internal/core/scribe/ingest.go | 11 ++++- internal/core/scribe/ingest_test.go | 42 +++++++++++++++++++ internal/surface/cli/scribe.go | 3 +- 6 files changed, 79 insertions(+), 7 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/31-scribe.md b/.abcd/development/brief/04-surfaces/31-scribe.md index 8b56b9297..8ea4cb96e 100644 --- a/.abcd/development/brief/04-surfaces/31-scribe.md +++ b/.abcd/development/brief/04-surfaces/31-scribe.md @@ -102,6 +102,16 @@ until all of the following hold: - **The run has no promoted scribe manifest.** The durable tier is write-once, so the refusal comes before any write rather than after the records land. +One scribe session per run lands records: this is a departure from the spec, +which assumes a rerun re-proves the same context and says nothing of a second +session. The promoted manifest is write-once beside the run, so once an ingest +has landed a record and promoted it, a later answer to that run is written with +the capture verbs directly, not through the scribe. An ingest that lands no +record — every item outstanding, or refused — promotes nothing, so it cannot +lock the run: its manifest stays parked, and a later session over the run is +assembled once the parked directory is cleared, or into an operator-named +directory. + The records are then written in payload order — dispositions, admissions, surprises — through the capture verbs' own functions, each under the ledger lock it takes for itself and each with the redaction and refusals it already applies. @@ -113,8 +123,9 @@ an admission whose ground differs from the standing acceptance's. The first refusal from any write stops the ingest and names what landed before it. Fidelity flags and refusals are carried into the result unresolved and never -into a record. Once every write has landed the manifest is promoted beside the -run through the reading store's one durable-tier writer, write-once. That +into a record. Once every write has landed, and when at least one record did, +the manifest is promoted beside the run through the reading store's one +durable-tier writer, write-once. That directory is denied to every assembly by the exclusion floor, so the next reading cannot see it. @@ -133,6 +144,11 @@ renders what landed first. file the operator names. A scribe session that learns its path and rewrites it before the ingest is outside what the verb can see; the host obligation covers it, since the session is handed the context and nothing else. +- Promotion follows what this ingest landed, not what the session landed. A + rerun after a partial ingest that drops everything that landed, leaving only + outstanding items, promotes nothing, so the records the first attempt wrote + have no promoted manifest beside the run; the parked one still names the + context they came from. - The state check reads words, not sense. A line that names a state only to negate it ("rdi-N: not accepted") still carries it, and a line naming two states carries both, so the verb refuses a state the item's line does not diff --git a/commands/scribe.md b/commands/scribe.md index 415455e13..ee9b8bf5e 100644 --- a/commands/scribe.md +++ b/commands/scribe.md @@ -116,7 +116,9 @@ hold, and any failure exits 2 naming the field and the item: - **Nothing is passed over in silence**: every item of the run with no standing disposition is answered, listed as outstanding, or named in a refusal. - **The run has no promoted scribe manifest yet**: the durable tier is - write-once, so a later answer to the run is written with the capture verbs. + write-once, so once a session has landed records over a run, a later answer + to it is written with the capture verbs. An ingest that lands no record + promotes nothing and leaves the run open to a later session. Then the dispositions, the admissions and the surprises are written, in that order, through the capture verbs' own functions, each with its own redaction and @@ -129,7 +131,8 @@ than minting it twice. Report from the JSON: the `dispositions`, `admissions` and `surprises` written, each with its id; `outstanding`; every `fidelity_flags` entry, **unresolved** — never pick one side of a flag, it is the researcher's to resolve; every -`refusals` entry; and `manifest`, the promoted manifest beside the run. Flags +`refusals` entry; and `manifest`, the promoted manifest beside the run, absent +when the ingest landed no record. Flags and refusals are never written into a record. **Binary resolution.** Run `"${CLAUDE_PLUGIN_ROOT}/abcd"` — a plugin install diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 6eda9569f..64e82a40a 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1601,7 +1601,8 @@ holds. Dispositions, admissions and surprises are then written in that order through the capture verbs, which apply their own redaction and refusals, the ordering gate included; the first refusal stops the ingest and names what landed before it. Fidelity flags and refusals are reported and never -written. Once every write has landed the manifest is promoted beside the run, write-once. +written. Once every write has landed, and when at least one record did, the manifest is promoted +beside the run, write-once; an ingest that lands no record leaves the run open. **Flags:** diff --git a/internal/core/scribe/ingest.go b/internal/core/scribe/ingest.go index 08f5e1f24..20508e653 100644 --- a/internal/core/scribe/ingest.go +++ b/internal/core/scribe/ingest.go @@ -130,7 +130,7 @@ type IngestResult struct { FidelityFlags []FidelityFlag `json:"fidelity_flags"` Refusals []Refusal `json:"refusals"` // Manifest is the promoted manifest's repository-relative path, set only - // once every write has landed. + // once every write has landed, and only when at least one record did. Manifest string `json:"manifest,omitempty"` } @@ -247,6 +247,15 @@ func Ingest(req IngestRequest) (IngestResult, error) { // Promotion comes LAST, so a refused ingest leaves the manifest parked and a // rerun re-proves the same context. The run directory is denied to every // assembly by the exclusion floor, so the next reading cannot see it. + // + // An ingest that landed nothing promotes nothing. The promoted manifest is + // write-once and locks the run against every later scribe session, so it is + // the evidence of a session that wrote records; a payload of outstanding + // items and refusals alone wrote none, and the researcher who answers next + // week must still be able to use the scribe for it. + if len(res.Landed()) == 0 { + return res, nil + } rel, err := reading.WriteRunArtefact(req.RepoRoot, out.Run, ManifestFileName, m) if err != nil { return res, fmt.Errorf("scribe: every record landed (%s) and promoting the manifest failed: %w", diff --git a/internal/core/scribe/ingest_test.go b/internal/core/scribe/ingest_test.go index 7456d4fc6..8206980f2 100644 --- a/internal/core/scribe/ingest_test.go +++ b/internal/core/scribe/ingest_test.go @@ -591,3 +591,45 @@ func TestScribeIngestAuthenticatesTheParkedPair(t *testing.T) { t.Fatalf("an ingest with no supplied dispositions was not refused: %v", err) } } + +// TestScribeIngestPromotesOnlyWhenARecordLanded: an ingest that lands nothing +// (every item outstanding, or refused) promotes nothing, so it cannot lock the +// run against the session that answers it later; the ingest that lands a +// record promotes the manifest as before. +func TestScribeIngestPromotesOnlyWhenARecordLanded(t *testing.T) { + s := assembleSession(t, positionDetection, 1, "{0}: accepted — "+groundA+".\n") + nothing := s.out() + nothing.Outstanding = []string{s.items[0]} + res, err := s.ingest(t, s.write(t, nothing)) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + if res.Manifest != "" { + t.Errorf("an ingest that landed nothing names a promoted manifest %s", res.Manifest) + } + if _, err := os.Stat(s.promoted()); !os.IsNotExist(err) { + t.Fatal("an ingest that landed nothing promoted the manifest, locking the run") + } + + refused := s.out() + refused.Refusals = []Refusal{{Subject: s.items[0], Reason: "the line is ambiguous"}} + if _, err := s.ingest(t, s.write(t, refused)); err != nil { + t.Fatalf("an all-refusal ingest after an all-outstanding one was refused: %v", err) + } + if _, err := os.Stat(s.promoted()); !os.IsNotExist(err) { + t.Fatal("an all-refusal ingest promoted the manifest") + } + + answer := s.out() + answer.Dispositions = []OutDisposition{{Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}} + res, err = s.ingest(t, s.write(t, answer)) + if err != nil { + t.Fatalf("the answering ingest was refused: %v", err) + } + if res.Manifest == "" { + t.Fatal("the ingest that landed a record did not promote the manifest") + } + if _, err := os.Stat(s.promoted()); err != nil { + t.Fatalf("the manifest is not beside the run: %v", err) + } +} diff --git a/internal/surface/cli/scribe.go b/internal/surface/cli/scribe.go index bb77d6143..fd833ec7c 100644 --- a/internal/surface/cli/scribe.go +++ b/internal/surface/cli/scribe.go @@ -127,7 +127,8 @@ func newScribeCommand(asJSON *bool) *cobra.Command { "Dispositions, admissions and surprises are then written in that order through the capture verbs,\n" + "which apply their own redaction and refusals, the ordering gate included; the first refusal stops\n" + "the ingest and names what landed before it. Fidelity flags and refusals are reported and never\n" + - "written. Once every write has landed the manifest is promoted beside the run, write-once.", + "written. Once every write has landed, and when at least one record did, the manifest is promoted\n" + + "beside the run, write-once; an ingest that lands no record leaves the run open.", Example: " abcd scribe ingest --scribe-json ./scribe-output.json --dispositions ./dispositions.md --json", Args: func(_ *cobra.Command, args []string) error { if len(args) > 0 { From b451103c91f0bb0e85a6f4a921ae4c5d1fb42ebb Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:49:37 +0100 Subject: [PATCH 55/78] =?UTF-8?q?chore:=20resolve=20iss-2609261036355918?= =?UTF-8?q?=20=E2=80=94=20an=20ingest=20that=20lands=20nothing=20leaves=20?= =?UTF-8?q?the=20run=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261036355918 Assisted-by: Claude:claude-opus-5-5 --- ...gest-promotes-the-manifest-beside-the-run-even-when.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261036355918-scribe-ingest-promotes-the-manifest-beside-the-run-even-when.md (50%) diff --git a/.abcd/work/issues/open/iss-2609261036355918-scribe-ingest-promotes-the-manifest-beside-the-run-even-when.md b/.abcd/work/issues/resolved/iss-2609261036355918-scribe-ingest-promotes-the-manifest-beside-the-run-even-when.md similarity index 50% rename from .abcd/work/issues/open/iss-2609261036355918-scribe-ingest-promotes-the-manifest-beside-the-run-even-when.md rename to .abcd/work/issues/resolved/iss-2609261036355918-scribe-ingest-promotes-the-manifest-beside-the-run-even-when.md index fd7d5d255..3f179b123 100644 --- a/.abcd/work/issues/open/iss-2609261036355918-scribe-ingest-promotes-the-manifest-beside-the-run-even-when.md +++ b/.abcd/work/issues/resolved/iss-2609261036355918-scribe-ingest-promotes-the-manifest-beside-the-run-even-when.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-scribe" origin: researcher-authored production_mode: hand-written found_at: "internal/core/scribe/ingest.go" +resolution: "scribe ingest promotes the manifest beside the run only when at least one record landed, so an all-outstanding or all-refusal ingest leaves the run open; the chapter states the one-landing-session rule as a departure and discloses the rerun residue" +impact: internal +resolved_by: + commit: "68c58b8f" --- scribe ingest promotes the manifest beside the run even when no record landed, so a payload of refusals or outstanding items alone locks the run against every later scribe session over it + +## Grounds + +- pursued: an ingest that lands no record leaves no promoted manifest and a later answering ingest over the run lands and promotes; a promoted manifest after an ingest that landed nothing would show it wrong From c74a932dee9e1fafa66d60966210bacb7dd53449 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:54:39 +0100 Subject: [PATCH 56/78] fix(scribe): refuse a symlinked ancestor of the ledger's allow list The assembler refused a symlink at each allow-list directory and inside it, but not above it: the walk runs inside the repository root, which follows a link that stays in-root, so a committed .abcd/work/issues link into docs/ carried shipped-tree content into the scribe context under ledger paths. The ingest's item listing had the same gap through a plain path read (the sibling sweep's find). Both now judge the ancestors first through capture.RefuseRedirectedLedger, a thin export of the ledgerDirs read form resolveRoots already applies to every capture verb, so the reader shares the one rule rather than carrying a copy of it. The scribe maps the refusal onto its ErrSymlink sentinel. Refs: iss-2609261036363663 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/31-scribe.md | 6 ++- commands/scribe.md | 5 ++- internal/core/capture/alloc.go | 15 ++++++++ internal/core/scribe/assemble.go | 19 ++++++++++ internal/core/scribe/assemble_test.go | 38 +++++++++++++++++++ internal/core/scribe/ingest.go | 5 +++ internal/core/scribe/ingest_test.go | 20 ++++++++++ 7 files changed, 105 insertions(+), 3 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/31-scribe.md b/.abcd/development/brief/04-surfaces/31-scribe.md index 8ea4cb96e..671bb59d8 100644 --- a/.abcd/development/brief/04-surfaces/31-scribe.md +++ b/.abcd/development/brief/04-surfaces/31-scribe.md @@ -42,7 +42,11 @@ from the issue ledger's own directory list — the reading records, dispositions admissions, surprises and reframes, and the three status directories — so a record family the ledger declares later is inside the scribe's world, and outside every reading's, by the same declaration. The collector walks those -directories and nothing else and refuses a symlink inside them; an allow-list +directories and nothing else and refuses a symlink inside them, and first +judges every directory above them — `.abcd`, `.abcd/work` and the issue ledger's +root — by the rule the capture verbs' own readers apply, because the walk +follows a link that stays inside the repository and a committed one there would +move the ledger into the shipped tree under ledger paths; an allow-list assertion then refuses any item whose path lies outside the list, whatever route it arrived by, so an item that reached the context by a future route is a refusal rather than a disclosure. The shipped tree, the brief, the intents, the diff --git a/commands/scribe.md b/commands/scribe.md index ee9b8bf5e..dd99ac880 100644 --- a/commands/scribe.md +++ b/commands/scribe.md @@ -46,8 +46,9 @@ The context carries every record under the issue ledger's own directories — th reading records, dispositions, admissions, surprises and reframes, and the open, resolved and won't-fix issues — derived from the ledger's directory list, so a record family the ledger declares later is included the day it is declared. -Nothing outside those directories is walked, a symlink inside them is refused, -and an item outside them is refused whatever route it arrived by. +Nothing outside those directories is walked, a symlink inside them or at any +directory above them is refused, and an item outside them is refused whatever +route it arrived by. Report from the JSON: `run`, `item_count`, `context_stamp`, `context_sha256` (the scribe's output cites it), `out_dir` and `artefacts`. The context and the diff --git a/internal/core/capture/alloc.go b/internal/core/capture/alloc.go index 6b2355241..6dd4abd44 100644 --- a/internal/core/capture/alloc.go +++ b/internal/core/capture/alloc.go @@ -55,6 +55,21 @@ func ensureLedgerDirs(repoRoot, issuesRoot string) error { return ledgerDirs(repoRoot, issuesRoot, true) } +// RefuseRedirectedLedger judges, creating nothing, every directory the default +// ledger of repoRoot is reached through — `.abcd`, `.abcd/work`, the issues root +// and its status directories — and refuses any that is a symlink or not a +// directory. It is ledgerDirs' read form, the one resolveRoots applies to every +// verb, exported for a reader outside this package that walks the ledger +// itself (the scribe's context assembler), so that reader judges the ancestors +// by this rule rather than by a copy of it. An absent directory is not a fault. +func RefuseRedirectedLedger(repoRoot string) error { + rr, err := filepath.Abs(repoRoot) + if err != nil { + return err + } + return ledgerDirs(rr, filepath.Join(rr, filepath.FromSlash(LedgerRelPath)), false) +} + // ledgerDirs judges every directory the ledger is reached through and every // directory the ledger IS, refusing any that exists as something other than a // real directory. Exactly three groups, in this order: diff --git a/internal/core/scribe/assemble.go b/internal/core/scribe/assemble.go index c6f22e76b..867a92334 100644 --- a/internal/core/scribe/assemble.go +++ b/internal/core/scribe/assemble.go @@ -196,6 +196,15 @@ func collectLedger(repoRoot string) ([]LedgerEntry, error) { } defer root.Close() + // The walk below runs inside the repository root, which follows a symlink + // that stays inside it, and the Lstat on each allow-list directory sees only + // the leaf. So the ANCESTORS are judged first, by the rule capture's own + // readers apply: the ledger moved into the shipped tree behind a committed + // link would otherwise reach the context under ledger paths. + if err := refuseRedirectedLedger(repoRoot); err != nil { + return nil, err + } + var out []LedgerEntry for _, dir := range AllowList() { fi, err := root.Lstat(dir) @@ -247,6 +256,16 @@ func collectLedger(repoRoot string) ([]LedgerEntry, error) { return out, nil } +// refuseRedirectedLedger is capture.RefuseRedirectedLedger under this package's +// symlink sentinel, so a caller tests one refusal whichever level held the link. +func refuseRedirectedLedger(repoRoot string) error { + if err := capture.RefuseRedirectedLedger(repoRoot); err != nil { + return fmt.Errorf("%w: the ledger is reached through a directory that is not a real one (%w); the "+ + "context is drawn from the ledger's own directories and a link above them is a route out", ErrSymlink, err) + } + return nil +} + // assertAllowList is the fail-closed half: every item must sit strictly inside // one allow-list directory, whatever route it arrived by, or the assembly is // refused naming it (adr-56). The collector's walk is the positive half; this is diff --git a/internal/core/scribe/assemble_test.go b/internal/core/scribe/assemble_test.go index db3265a61..8f898163d 100644 --- a/internal/core/scribe/assemble_test.go +++ b/internal/core/scribe/assemble_test.go @@ -310,3 +310,41 @@ func TestScribeAssembleRefusesASymlinkedLedgerDirectory(t *testing.T) { t.Fatalf("a symlinked ledger directory was not refused: %v", err) } } + +// TestScribeAssembleRefusesASymlinkedLedgerAncestor: a link ABOVE an allow-list +// directory is the same route out, and one that stays inside the repository is +// followed by the root the walk runs in. The ledger moved into the shipped tree +// behind a committed link would hand the scribe shipped content under ledger +// paths, so every ancestor is judged by capture's own rule and refused. +func TestScribeAssembleRefusesASymlinkedLedgerAncestor(t *testing.T) { + for _, tc := range []struct{ name, linked, target string }{ + {"the issue ledger", capture.LedgerRelPath, "../../docs/shadow"}, + {"the shared working tier", ".abcd/work", "../docs/shadow"}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newFixture(t, positionDetection, 1) + linked := filepath.Join(f.repo, filepath.FromSlash(tc.linked)) + shadow := filepath.Join(f.repo, "docs", "shadow") + if err := os.Rename(linked, shadow); err != nil { + t.Fatal(err) + } + if err := os.Symlink(tc.target, linked); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + plantedRel := "open/planted.md" + if tc.linked == ".abcd/work" { + plantedRel = "issues/open/planted.md" + } + writeFile(t, shadow, plantedRel, "SENTINEL-SHIPPED-SHADOW\n") + res, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: fixtureRun, DispositionsPath: supply(t, suppliedText), DryRun: true}) + if err == nil || !errors.Is(err, ErrSymlink) { + for _, e := range res.Context.Ledger { + if strings.Contains(e.Text, "SENTINEL-SHIPPED-SHADOW") { + t.Errorf("the shadowed shipped file reached the context as %s", e.Path) + } + } + t.Fatalf("a symlinked ancestor of the allow list was not refused: %v", err) + } + }) + } +} diff --git a/internal/core/scribe/ingest.go b/internal/core/scribe/ingest.go index 20508e653..a78a5424f 100644 --- a/internal/core/scribe/ingest.go +++ b/internal/core/scribe/ingest.go @@ -440,6 +440,11 @@ func refusePromoted(repoRoot, run string) error { // runItems lists the run's reading items as the store holds them, each with // whether a disposition already stands over it. func runItems(repoRoot, run string) (map[string]bool, error) { + // The listing is a plain path read, so the directories above it are judged + // first by the same rule the assembly applies. + if err := refuseRedirectedLedger(repoRoot); err != nil { + return nil, err + } dir := filepath.Join(repoRoot, filepath.FromSlash(runRecordsDir(run))) entries, err := os.ReadDir(dir) if err != nil { diff --git a/internal/core/scribe/ingest_test.go b/internal/core/scribe/ingest_test.go index 8206980f2..4b01c0f58 100644 --- a/internal/core/scribe/ingest_test.go +++ b/internal/core/scribe/ingest_test.go @@ -633,3 +633,23 @@ func TestScribeIngestPromotesOnlyWhenARecordLanded(t *testing.T) { t.Fatalf("the manifest is not beside the run: %v", err) } } + +// TestScribeIngestRefusesASymlinkedLedgerAncestor: the ingest lists the run's +// items through a plain path, so the ledger's ancestors are judged there too; a +// ledger redirected after the assembly is refused before the listing. +func TestScribeIngestRefusesASymlinkedLedgerAncestor(t *testing.T) { + s := assembleSession(t, positionDetection, 1, "{0}: accepted — "+groundA+".\n") + linked := filepath.Join(s.repo, filepath.FromSlash(capture.LedgerRelPath)) + shadow := filepath.Join(s.repo, "docs", "shadow") + if err := os.Rename(linked, shadow); err != nil { + t.Fatal(err) + } + if err := os.Symlink("../../docs/shadow", linked); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + o := s.out() + o.Outstanding = []string{s.items[0]} + if _, err := s.ingest(t, s.write(t, o)); !errors.Is(err, ErrSymlink) { + t.Fatalf("an ingest through a symlinked ledger ancestor was not refused: %v", err) + } +} From 7dd93c7b4fc95c823902e37076bad6520d2e533e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:54:47 +0100 Subject: [PATCH 57/78] =?UTF-8?q?chore:=20resolve=20iss-2609261036363663?= =?UTF-8?q?=20=E2=80=94=20a=20symlinked=20ledger=20ancestor=20is=20refused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261036363663 Assisted-by: Claude:claude-opus-5-5 --- ...semble-refuses-a-symlink-at-an-allow-list-directory.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261036363663-scribe-assemble-refuses-a-symlink-at-an-allow-list-directory.md (55%) diff --git a/.abcd/work/issues/open/iss-2609261036363663-scribe-assemble-refuses-a-symlink-at-an-allow-list-directory.md b/.abcd/work/issues/resolved/iss-2609261036363663-scribe-assemble-refuses-a-symlink-at-an-allow-list-directory.md similarity index 55% rename from .abcd/work/issues/open/iss-2609261036363663-scribe-assemble-refuses-a-symlink-at-an-allow-list-directory.md rename to .abcd/work/issues/resolved/iss-2609261036363663-scribe-assemble-refuses-a-symlink-at-an-allow-list-directory.md index cc406927d..14ffa7a61 100644 --- a/.abcd/work/issues/open/iss-2609261036363663-scribe-assemble-refuses-a-symlink-at-an-allow-list-directory.md +++ b/.abcd/work/issues/resolved/iss-2609261036363663-scribe-assemble-refuses-a-symlink-at-an-allow-list-directory.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-scribe" origin: researcher-authored production_mode: hand-written found_at: "internal/core/scribe/assemble.go" +resolution: "scribe assemble and the ingest's item listing judge every directory above the allow list through capture.RefuseRedirectedLedger, the ledgerDirs read form every capture verb applies, and refuse a symlinked ancestor" +impact: internal +resolved_by: + commit: "c74a932d" --- scribe assemble refuses a symlink at an allow-list directory and inside it but not at its ancestors (.abcd, .abcd/work, .abcd/work/issues): os.Root follows an in-root link, so a committed issues -> docs/shadow link carries shipped-tree content into the scribe context under ledger paths + +## Grounds + +- pursued: a committed symlink at .abcd/work or .abcd/work/issues refuses the assembly and the ingest before any ledger content is read; a shadowed shipped file reaching the context under a ledger path would show it wrong From 6aae3990b84bc6222bd92a797fa81da8a4fc4eea Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:55:56 +0100 Subject: [PATCH 58/78] fix(scribe): percent-encode hidden runes in the ingest's JSON render The refusals and fidelity flags are the scribe's free text, carried back unresolved. The text render masked them through termsafe; the JSON render handed them on raw, and encoding/json leaves DEL, the C1 range, bidi overrides and zero-width runes in place. The JSON render now carries them through termsafe.EncodeHiddenRunes, the canonical encoder for the JSON boundary, so each value is kept whole and cannot reorder or drive the terminal that prints it. Refs: iss-2609261036366114 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/31-scribe.md | 4 +- commands/scribe.md | 4 +- internal/surface/cli/scribe.go | 25 ++++++++- internal/surface/cli/scribe_surface_test.go | 55 +++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/31-scribe.md b/.abcd/development/brief/04-surfaces/31-scribe.md index 671bb59d8..02db0601b 100644 --- a/.abcd/development/brief/04-surfaces/31-scribe.md +++ b/.abcd/development/brief/04-surfaces/31-scribe.md @@ -127,7 +127,9 @@ an admission whose ground differs from the standing acceptance's. The first refusal from any write stops the ingest and names what landed before it. Fidelity flags and refusals are carried into the result unresolved and never -into a record. Once every write has landed, and when at least one record did, +into a record. They are the scribe's free text, so the text render masks the +terminal-control and hidden runes they carry and the JSON render percent-encodes +them, keeping each value whole. Once every write has landed, and when at least one record did, the manifest is promoted beside the run through the reading store's one durable-tier writer, write-once. That directory is denied to every assembly by the exclusion floor, so the next diff --git a/commands/scribe.md b/commands/scribe.md index dd99ac880..1472cb8bd 100644 --- a/commands/scribe.md +++ b/commands/scribe.md @@ -134,7 +134,9 @@ each with its id; `outstanding`; every `fidelity_flags` entry, **unresolved** never pick one side of a flag, it is the researcher's to resolve; every `refusals` entry; and `manifest`, the promoted manifest beside the run, absent when the ingest landed no record. Flags -and refusals are never written into a record. +and refusals are never written into a record; in the JSON a hidden or +terminal-control rune in either arrives percent-encoded (`%E2%80%AE`), so quote +the value as it stands. **Binary resolution.** Run `"${CLAUDE_PLUGIN_ROOT}/abcd"` — a plugin install provisions the binary into the plugin root, so this is the rung that fires for a diff --git a/internal/surface/cli/scribe.go b/internal/surface/cli/scribe.go index fd833ec7c..b9d14781a 100644 --- a/internal/surface/cli/scribe.go +++ b/internal/surface/cli/scribe.go @@ -158,13 +158,13 @@ func newScribeCommand(asJSON *bool) *cobra.Command { // A refusal after something landed discloses what landed, before it // exits: the operator's handle on the partial state is the render. if len(res.Landed()) > 0 { - _ = render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + _ = render(cmd.OutOrStdout(), *asJSON, jsonSafeIngest(res), func(w io.Writer) { renderScribeIngest(w, res) }) } return scribeRefusal("scribe ingest", err) } - return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + return render(cmd.OutOrStdout(), *asJSON, jsonSafeIngest(res), func(w io.Writer) { renderScribeIngest(w, res) }) }, @@ -210,6 +210,27 @@ func renderScribeAssemble(w io.Writer, res scribe.AssembleResult) { fmt.Fprintln(w, "Hand the context, and nothing else, to a scribe session that is not a reading session.") } +// jsonSafeIngest is the ingest result as the JSON render carries it. The +// refusals and the fidelity flags are the scribe's free text, carried back +// unresolved and validated by nothing, and encoding/json leaves DEL, the C1 +// range, bidi overrides and zero-width runes raw. The text render masks them; +// the JSON render percent-encodes them through termsafe's JSON-boundary encoder, +// so the value is kept whole and cannot reorder or drive a terminal that prints +// it. Every other string in the result is an id or a state the ingest already +// held to its grammar. +func jsonSafeIngest(res scribe.IngestResult) scribe.IngestResult { + flags := make([]scribe.FidelityFlag, len(res.FidelityFlags)) + for i, f := range res.FidelityFlags { + flags[i] = scribe.FidelityFlag{First: termsafe.EncodeHiddenRunes(f.First), Second: termsafe.EncodeHiddenRunes(f.Second)} + } + refusals := make([]scribe.Refusal, len(res.Refusals)) + for i, r := range res.Refusals { + refusals[i] = scribe.Refusal{Subject: termsafe.EncodeHiddenRunes(r.Subject), Reason: termsafe.EncodeHiddenRunes(r.Reason)} + } + res.FidelityFlags, res.Refusals = flags, refusals + return res +} + // renderScribeIngest writes one ingest's text render. Every payload-derived // string is neutralised before it reaches the terminal. func renderScribeIngest(w io.Writer, res scribe.IngestResult) { diff --git a/internal/surface/cli/scribe_surface_test.go b/internal/surface/cli/scribe_surface_test.go index 733104451..9799ede7c 100644 --- a/internal/surface/cli/scribe_surface_test.go +++ b/internal/surface/cli/scribe_surface_test.go @@ -207,3 +207,58 @@ func TestScribeIngestRendersOnRefusal(t *testing.T) { } _ = repo } + +// TestScribeIngestJSONEncodesHiddenRunes: refusals and fidelity flags are the +// scribe's free text, carried back unresolved. The text render masks what it +// quotes; the JSON render must not hand the same runes on raw, because +// encoding/json leaves DEL, the C1 range, bidi overrides and zero-width runes +// in place. They are percent-encoded through termsafe's JSON-boundary encoder. +func TestScribeIngestJSONEncodesHiddenRunes(t *testing.T) { + _, item := scribeRepo(t) + disp := filepath.Join(t.TempDir(), "dispositions.md") + if err := os.WriteFile(disp, []byte(item+": not decided yet\n"), 0o644); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + if code := Run([]string{"scribe", "assemble", "--run", "rdg-2609250000000001", "--dispositions", disp, "--json"}, + &stdout, &stderr); code != 0 { + t.Fatalf("assemble exited %d: %s", code, stderr.String()) + } + var asm scribe.AssembleResult + if err := json.Unmarshal(stdout.Bytes(), &asm); err != nil { + t.Fatal(err) + } + bidi, c1, zw := string(rune(0x202e)), string(rune(0x9b)), string(rune(0x200b)) + payload := map[string]any{ + "_type": scribe.OutputType, "run": "rdg-2609250000000001", "context_sha256": asm.ContextSHA256, + "outstanding": []string{item}, + "refusals": []map[string]any{{"subject": "a line" + bidi, "reason": "ambiguous" + c1 + "[2J"}}, + "fidelity_flags": []map[string]any{{"first": "one" + zw, "second": "two" + bidi}}, + } + raw, _ := json.Marshal(payload) + p := filepath.Join(t.TempDir(), "out.json") + if err := os.WriteFile(p, raw, 0o644); err != nil { + t.Fatal(err) + } + stdout.Reset() + stderr.Reset() + if code := Run([]string{"scribe", "ingest", "--scribe-json", p, "--dispositions", disp, "--json"}, + &stdout, &stderr); code != 0 { + t.Fatalf("ingest exited %d: %s", code, stderr.String()) + } + var res scribe.IngestResult + if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { + t.Fatalf("the JSON render does not parse: %v\n%s", err, stdout.String()) + } + for _, s := range []string{res.Refusals[0].Subject, res.Refusals[0].Reason, + res.FidelityFlags[0].First, res.FidelityFlags[0].Second} { + for _, r := range []string{bidi, c1, zw} { + if strings.Contains(s, r) { + t.Errorf("the JSON render carries a hidden rune raw in %q", s) + } + } + } + if !strings.Contains(res.Refusals[0].Reason, "%C2%9B") { + t.Errorf("the C1 byte was not encoded losslessly: %q", res.Refusals[0].Reason) + } +} From bfab32e23f5610086ea5a571b5f8d8a5b0eb7d20 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:56:06 +0100 Subject: [PATCH 59/78] =?UTF-8?q?chore:=20resolve=20iss-2609261036366114?= =?UTF-8?q?=20=E2=80=94=20the=20ingest=20JSON=20encodes=20hidden=20runes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261036366114 Assisted-by: Claude:claude-opus-5-5 --- ...ngest-json-renders-refusals-reason-refusals-subject.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261036366114-scribe-ingest-json-renders-refusals-reason-refusals-subject.md (57%) diff --git a/.abcd/work/issues/open/iss-2609261036366114-scribe-ingest-json-renders-refusals-reason-refusals-subject.md b/.abcd/work/issues/resolved/iss-2609261036366114-scribe-ingest-json-renders-refusals-reason-refusals-subject.md similarity index 57% rename from .abcd/work/issues/open/iss-2609261036366114-scribe-ingest-json-renders-refusals-reason-refusals-subject.md rename to .abcd/work/issues/resolved/iss-2609261036366114-scribe-ingest-json-renders-refusals-reason-refusals-subject.md index 1bf85f052..5243b4b12 100644 --- a/.abcd/work/issues/open/iss-2609261036366114-scribe-ingest-json-renders-refusals-reason-refusals-subject.md +++ b/.abcd/work/issues/resolved/iss-2609261036366114-scribe-ingest-json-renders-refusals-reason-refusals-subject.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-scribe" origin: researcher-authored production_mode: hand-written found_at: "internal/surface/cli/scribe.go" +resolution: "scribe ingest's JSON render carries refusals and fidelity flags through termsafe.EncodeHiddenRunes, the canonical JSON-boundary encoder, so hidden and terminal-control runes arrive percent-encoded" +impact: internal +resolved_by: + commit: "6aae3990" --- scribe ingest --json renders refusals[].reason, refusals[].subject and the fidelity flags, which are scribe free text, raw: the text render neutralises them through termsafe and the JSON render leaves bidi and C1 controls in place (the iss-359 class) + +## Grounds + +- pursued: a refusal or flag carrying a bidi, C1 or zero-width rune renders in JSON with that rune percent-encoded; the raw rune in ingest --json output would show it wrong From 86c00ffa46931af1f4bc613393886e8d74cf42f9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:56:52 +0100 Subject: [PATCH 60/78] docs(scribe): record the lane's departures and disclose the verbatim residue One dated DECISIONS entry records the three departures the review found held only in code or the chapter: chapter 31 rather than 24, SessionSeparation(repoRoot, rootSHA), and the working-tree read where the intent's scope condition says committed content. The last is not decided: it is captured as a question owed. The chapter discloses that the verbatim check is a substring test over the whole folded supplied text, so a ground may be another item's words, and states which half of the ledger read is true today. Refs: iss-2609261056373310 Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/brief/04-surfaces/31-scribe.md | 13 +++++++++++-- .abcd/work/DECISIONS.md | 1 + ...reads-the-ledger-as-it-stands-in-the-working.md | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 .abcd/work/issues/open/iss-2609261056373310-scribe-assemble-reads-the-ledger-as-it-stands-in-the-working.md diff --git a/.abcd/development/brief/04-surfaces/31-scribe.md b/.abcd/development/brief/04-surfaces/31-scribe.md index 02db0601b..0b9c95a54 100644 --- a/.abcd/development/brief/04-surfaces/31-scribe.md +++ b/.abcd/development/brief/04-surfaces/31-scribe.md @@ -140,8 +140,11 @@ renders what landed first. ## Disclosed limits -- The context is assembled from the ledger as it stands on disk. A scribe that - needs ledger content the working tree does not hold is outside this scope. +- The context is assembled from the ledger as it stands in the working tree, + uncommitted records included, where the intent's scope condition speaks of + committed ledger content; which of the two moves is recorded as a question + owed (iss-2609261056373310). A scribe that needs ledger content the working + tree does not hold is outside this scope. - The verb cannot enforce what a host hands a session. The plugin surface states the obligation, and the separation check can only see what a host retained: where a host assembles context before anything is retained, the check reports @@ -155,6 +158,12 @@ renders what landed first. outstanding items, promotes nothing, so the records the first attempt wrote have no promoted manifest beside the run; the parked one still names the context they came from. +- The verbatim check is a substring test over the whole supplied text once + whitespace is folded. It proves every word was already there, not that the + words answer the item they are filed under: a ground may be another item's + remark, or a run of words that crosses from one item's line into the next. + The state and the admission are held to the item's own line; a ground is held + to the text. - The state check reads words, not sense. A line that names a state only to negate it ("rdi-N: not accepted") still carries it, and a line naming two states carries both, so the verb refuses a state the item's line does not diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index f2d90feb3..c973a29d9 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2539,3 +2539,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-25 — The load check's stray rule is "busy for its share" (ruling H1, the product thinker via the interview session, 07:57Z, on iss-2609231947544298). A long-running process outside abcd's lanes is a stray when it uses nearly all the CPU it could get on the machine as loaded: its lifetime CPU share is measured against its fair share, the online cores divided by the runnable demand, not against a fixed 0.9 of one core, so forty busy loops each at a fortieth of the machine all count. The share test applies to the caller's own processes and to other accounts' alike, and other accounts' strays stay counted only. It is not a second sample and not a summed-cores trigger. The build reads the runnable demand as the snapshot's one-minute load average and caps the fair share at one core, so on a machine loaded no higher than its cores the rule is the near-full core it was (`machineload.FairShare`, spc-2609232027132755). This closes the band between 1.125 and 4 times the cores in which the check said nothing (pinned by `TestStrayRuleSilentBand`, succeeded by `TestStrayRuleCoversTheOversubscribedBand`), and lets the load check's remainder spec close and itd-2609231434459890 ship. The check still warns and never refuses (the 2026-09-23 entry above on that intent). - 2026-09-25 — Autonomous run A defers every open capture routed to the product thinker out loud to v0.10.0, under the product thinker's directive of 2026-09-25 ("I want the ledger drained": a capture ends fixed, wontfix with its reason, closed as a duplicate, or deferred out loud where it needs a product-thinker ruling or a planning interview). The product thinker is away, so no one in the run can give those rulings. 190 records each carry `deferred_after: "v0.10.0"` and a `deferral_reason` that quotes the ruling owed verbatim. 184 of them renew a v0.9.0 grant that lapsed when v0.10.0 re-anchored, and 6 carried none. Every question is asked once in the run's rulings-owed list, grouped under the routing pass's eleven themes: A, planning interviews already ruled "plan next cycle" (27); B, confirmations owed on rulings already given (8); C, dependency and publish sign-offs (6); D, narrowing a shipped promise (4); E, principles and conventions to adopt (23); F, record schema and lint rules (34); G, security and trust design forks (16); H, autonomous runs, implement and multi-agent planning (22); I, site, docs voice and product story (17); J, future capabilities to plan or close (27); K, parked on a trigger, or a human act outside the tree (6). Within each theme the questions covering a major record come first, and the list is the agenda for the next interview. The same pass closes 9 duplicates and 44 captures on their recorded merits, so none of those is deferred (implementer of lane records1). - 2026-09-25 — itd-2609020625400194 (admission and surprise verbs) is delivered on main by autonomous run A's lane, built against the current main, and not by the parked `phase-9/admit` branch that delivered the same spec on 2026-09-04. Phase 9 stays parked on the product thinker's word of 2026-09-22 ("leave both parked workstreams"). Its build branch sits 213 commits behind main and ships six other intents that exist nowhere else, so the run neither merges nor edits it. The overlap is this one intent: when Phase 9 is unparked, its admission commits (the spec close in 30f42daa, with b0334b03, 5ca30f92 and 074dea98) are dropped or reconciled against this delivery. That reconciliation is owed to the product thinker and listed in the run's rulings-owed list. The ruling is reversible: nothing on the parked branches is touched (orchestrator abcd-39, autonomous run A). +- 2026-09-26 — Three departures the scribe lane (itd-2609020625402599, spc-2609020626045177) made from its closed spec, which its review found recorded only in code, the chapter or the lane report, recorded here (implementer of lane fix2-scribe, autonomous run A). First, the surface chapter is `04-surfaces/31-scribe.md`, not the `24-scribe.md` the spec names: row 24 is `decide`'s, taken before the lane landed, so the chapter took the next free row. The spec is closed and keeps its text; four open lanes claim row 31 (build, lab, source ledger and this one), so the integration step renumbers three of them. Second, the transcript store's check is `SessionSeparation(repoRoot, rootSHA)`, not the `SessionSeparation(rootSHA)` the spec names, because it reads through `history.List`, which takes the repository root to find a checkout's opt-in per-repo transcript store; the report is unchanged. Third, the scribe's context is assembled from the ledger as it stands in the working tree, uncommitted records included, while the intent's scope condition (cond-2609020626046719) says committed ledger content. The working-tree read is what the code does today and the chapter says so. Whether the condition or the code should move is not decided here: it is captured as iss-2609261056373310, a ruling owed. diff --git a/.abcd/work/issues/open/iss-2609261056373310-scribe-assemble-reads-the-ledger-as-it-stands-in-the-working.md b/.abcd/work/issues/open/iss-2609261056373310-scribe-assemble-reads-the-ledger-as-it-stands-in-the-working.md new file mode 100644 index 000000000..34e3cda4c --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261056373310-scribe-assemble-reads-the-ledger-as-it-stands-in-the-working.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609261056373310" +slug: "scribe-assemble-reads-the-ledger-as-it-stands-in-the-working" +severity: "minor" +category: "inconsistency" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-scribe" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/scribe/assemble.go" +--- + +scribe assemble reads the ledger as it stands in the working tree, uncommitted records included, while itd-2609020625402599's scope condition (cond-2609020626046719) says the context is assembled from committed ledger content; which one moves is a ruling owed: amend the condition to the working tree, or have the assembler read committed content (and refuse or report uncommitted records) From 14679e154f9a04d2119c2925e88c8a390645368a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:40:33 +0100 Subject: [PATCH 61/78] chore: capture the setext principle title the projection drops Refs: iss-2609261140284421 Assisted-by: Claude:claude-opus-5-5 --- ...140284421-setext-principle-title-not-carried.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609261140284421-setext-principle-title-not-carried.md diff --git a/.abcd/work/issues/open/iss-2609261140284421-setext-principle-title-not-carried.md b/.abcd/work/issues/open/iss-2609261140284421-setext-principle-title-not-carried.md new file mode 100644 index 000000000..a1b40c1a0 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261140284421-setext-principle-title-not-carried.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609261140284421" +slug: "setext-principle-title-not-carried" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review2-principles LOW" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lint/principles.go" +--- + +A principle whose H1 title is written in setext form (the title line underlined with ===) travels to a reading without its title: principleTitleRe in internal/core/lint/principles.go:127 matches ATX headings only, so the projection sends the statement paragraph bare, and a cold reader gets a sentence with no name. Both readers agree, so no gate disagrees; the promise that a principle is readable cold does not hold for that file shape. From 795c72ed3ad1d11cc84987bbb0f967eefa9467a4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:05:23 +0100 Subject: [PATCH 62/78] chore: capture review2-scribe's three LOW findings on scribe ingest The state check grants a line's state to every item the line names; it splits lines on LF alone; and the run listing follows a symlinked readings or run directory. Refs: iss-2609261205178776, iss-2609261205176571, iss-2609261205185463 Assisted-by: Claude:claude-opus-5-5 --- ...t-s-per-line-state-check-splits-the-supplied.md | 14 ++++++++++++++ ...s-state-check-grants-a-state-to-every-item-a.md | 14 ++++++++++++++ ...ingest-lists-a-run-s-reading-items-through-a.md | 14 ++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609261205176571-scribe-ingest-s-per-line-state-check-splits-the-supplied.md create mode 100644 .abcd/work/issues/open/iss-2609261205178776-scribe-ingest-s-state-check-grants-a-state-to-every-item-a.md create mode 100644 .abcd/work/issues/open/iss-2609261205185463-scribe-ingest-lists-a-run-s-reading-items-through-a.md diff --git a/.abcd/work/issues/open/iss-2609261205176571-scribe-ingest-s-per-line-state-check-splits-the-supplied.md b/.abcd/work/issues/open/iss-2609261205176571-scribe-ingest-s-per-line-state-check-splits-the-supplied.md new file mode 100644 index 000000000..ca5274ad3 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261205176571-scribe-ingest-s-per-line-state-check-splits-the-supplied.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609261205176571" +slug: "scribe-ingest-s-per-line-state-check-splits-the-supplied" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review2-scribe" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/scribe/ingest.go" +--- + +scribe ingest's per-line state check splits the supplied dispositions on LF alone, so a text whose lines end in CR, U+2028 or U+2029 is one line and the check collapses to a whole-text test: '{0}: rejected ... CR {1}: accepted ... CR' wrote {0} accepted (probed by review2-scribe; CRLF is unaffected). lineCarries in internal/core/scribe/ingest.go must split on CRLF, CR, LF, U+2028 and U+2029. diff --git a/.abcd/work/issues/open/iss-2609261205178776-scribe-ingest-s-state-check-grants-a-state-to-every-item-a.md b/.abcd/work/issues/open/iss-2609261205178776-scribe-ingest-s-state-check-grants-a-state-to-every-item-a.md new file mode 100644 index 000000000..39ae0334f --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261205178776-scribe-ingest-s-state-check-grants-a-state-to-every-item-a.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609261205178776" +slug: "scribe-ingest-s-state-check-grants-a-state-to-every-item-a" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review2-scribe" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/scribe/ingest.go" +--- + +scribe ingest's state check grants a state to every item a line names: lineCarries in internal/core/scribe/ingest.go reads the whole line that mentions the item, so a line such as 'rdi-2: accepted, unlike rdi-1' carries 'accepted' for rdi-1 too, and a payload filing rdi-1 as accepted passes the check the researcher's own line contradicts (probed by review2-scribe: {1}: accepted ... (unlike {0}) wrote {0} accepted). The state must be held to the part of the line that belongs to the item. diff --git a/.abcd/work/issues/open/iss-2609261205185463-scribe-ingest-lists-a-run-s-reading-items-through-a.md b/.abcd/work/issues/open/iss-2609261205185463-scribe-ingest-lists-a-run-s-reading-items-through-a.md new file mode 100644 index 000000000..57f70c969 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261205185463-scribe-ingest-lists-a-run-s-reading-items-through-a.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609261205185463" +slug: "scribe-ingest-lists-a-run-s-reading-items-through-a" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review2-scribe" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/scribe/ingest.go" +--- + +scribe ingest lists a run's reading items through a directory whose own link status it never judges: runItems in internal/core/scribe/ingest.go refuses a symlinked .abcd, .abcd/work, issues root or status directory through capture.RefuseRedirectedLedger, but a symlinked .abcd/work/issues/readings or run directory is followed by os.ReadDir, so the listing is drawn from outside the ledger (probed by review2-scribe: nil) where scribe assemble refuses the same link. The readings directory and the run directory are to be Lstat-refused by the primitive capture's own ledger walk uses. From 118b46a2a7fda5ce7e778d702a80eec7dec21fd4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:07:04 +0100 Subject: [PATCH 63/78] fix(scribe): end the state check's lines at CR and the Unicode separators lineCarries split the supplied dispositions on LF alone, so a text whose lines end in CR, U+2028 or U+2029 was one line and the per-line state check collapsed to a whole-text one: a state on the next item's line was granted to this one. Lines now end at LF, CR (CRLF included), U+2028 and U+2029. Refs: iss-2609261205176571 Assisted-by: Claude:claude-opus-5-5 --- internal/core/scribe/ingest.go | 18 ++++++++++- internal/core/scribe/ingest_test.go | 50 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/internal/core/scribe/ingest.go b/internal/core/scribe/ingest.go index a78a5424f..983b24d38 100644 --- a/internal/core/scribe/ingest.go +++ b/internal/core/scribe/ingest.go @@ -622,8 +622,12 @@ var admissionTokens = []string{issueschema.DispositionAccepted, "admit", "admits // line, and a word that merely contains it ("unaccepted") does not carry it. It // reads words, not sense, so a line that names a state to negate it still // carries it; that residue is the chapter's to disclose. +// +// A line ends at any terminator a researcher's editor writes (lineBreak): split +// on LF alone, a text whose lines end in CR or a Unicode separator is one line, +// and the per-line check collapses to a whole-text one. func lineCarries(supplied, id string, tokens ...string) bool { - for _, line := range strings.Split(supplied, "\n") { + for _, line := range strings.FieldsFunc(supplied, lineBreak) { if !mentions(line, id) { continue } @@ -636,6 +640,18 @@ func lineCarries(supplied, id string, tokens ...string) bool { return false } +// lineBreak reports whether r ends a line: LF, CR (so CRLF too, the empty field +// between them dropped), U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR. +// The code points are written as numbers so no layer between the author and +// the compiler can decode an escape into the wrong byte. +func lineBreak(r rune) bool { + switch r { + case 0x0a, 0x0d, 0x2028, 0x2029: + return true + } + return false +} + // wholeWord reports whether text holds word, ignoring case, bounded on each side // by the text's edge or a character that is neither a letter nor a digit. func wholeWord(text, word string) bool { diff --git a/internal/core/scribe/ingest_test.go b/internal/core/scribe/ingest_test.go index 4b01c0f58..bf62a1ddc 100644 --- a/internal/core/scribe/ingest_test.go +++ b/internal/core/scribe/ingest_test.go @@ -653,3 +653,53 @@ func TestScribeIngestRefusesASymlinkedLedgerAncestor(t *testing.T) { t.Fatalf("an ingest through a symlinked ledger ancestor was not refused: %v", err) } } + +// Line terminators, built from their code points so no layer between the +// author and the compiler can decode an escape into the wrong byte. +var ( + termLF = string(rune(0x0a)) + termCR = string(rune(0x0d)) + termCRLF = termCR + termLF + termLS = string(rune(0x2028)) + termPS = string(rune(0x2029)) +) + +// TestScribeIngestHoldsTheStateToTheItemsLineWhateverEndsIt: the state is held +// to the item's own line, and a line ends at any of the terminators a +// researcher's editor writes. A text whose lines end in CR, U+2028 or U+2029 is +// several lines, so one item's state is not another's (iss-2609261205176571). +func TestScribeIngestHoldsTheStateToTheItemsLineWhateverEndsIt(t *testing.T) { + for name, term := range map[string]string{ + "LF": termLF, "CRLF": termCRLF, "CR": termCR, "U+2028": termLS, "U+2029": termPS, + } { + t.Run(name, func(t *testing.T) { + s := assembleSession(t, positionDetection, 2, + "{0}: rejected — "+groundA+"."+term+"{1}: accepted — "+groundA+"."+term) + before := s.ledger(t) + o := s.out() + o.Dispositions = []OutDisposition{ + {Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}, + {Item: s.items[1], State: issueschema.DispositionAccepted, Grounds: groundA}, + } + _, err := s.ingest(t, s.write(t, o)) + if err == nil || !strings.Contains(err.Error(), "state") || !strings.Contains(err.Error(), s.items[0]) { + t.Fatalf("a state carried only by the next line (%s-terminated) was granted to the item: %v", name, err) + } + if s.ledger(t) != before { + t.Fatal("a refused payload changed the ledger") + } + + // The rulings the lines do give land. + s2 := assembleSession(t, positionDetection, 2, + "{0}: rejected — "+groundA+"."+term+"{1}: accepted — "+groundA+"."+term) + o2 := s2.out() + o2.Dispositions = []OutDisposition{ + {Item: s2.items[0], State: issueschema.DispositionRejected, Grounds: groundA}, + {Item: s2.items[1], State: issueschema.DispositionAccepted, Grounds: groundA}, + } + if _, err := s2.ingest(t, s2.write(t, o2)); err != nil { + t.Fatalf("the states the %s-terminated lines carry were refused: %v", name, err) + } + }) + } +} From e8c12571c234f32de001c5703172d16d81102016 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:10:38 +0100 Subject: [PATCH 64/78] fix(scribe): hold a state to the item's own part of a line naming several lineCarries granted a state to every item the line mentioned, so a line such as "rdi-2: accepted, unlike rdi-1" filed rdi-1 as accepted. A line that names one item is still read whole; on a line that names several, each item owns the text from its id to the next item id. Chosen over refusing every multi-item line: that refusal would also refuse the item a cross-referencing line does answer ("rdi-2: accepted, unlike rdi-1" leaves rdi-2 with no line of its own), and the researcher writes such lines naturally. The residue is positional, not semantic, and the chapter and the command page name it: a ruling shared across ids reaches only the id it follows, one ahead of every id reaches none (both refused), and one following an item named in passing is granted to that item. Refs: iss-2609261205178776 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/31-scribe.md | 18 ++++- commands/scribe.md | 7 +- internal/core/scribe/ingest.go | 80 +++++++++++++++++-- internal/core/scribe/ingest_test.go | 55 +++++++++++++ 4 files changed, 146 insertions(+), 14 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/31-scribe.md b/.abcd/development/brief/04-surfaces/31-scribe.md index 0b9c95a54..559065219 100644 --- a/.abcd/development/brief/04-surfaces/31-scribe.md +++ b/.abcd/development/brief/04-surfaces/31-scribe.md @@ -90,10 +90,11 @@ until all of the following hold: level, so a key the scribe may not author is refused by name with the entry it sat on. A disposition or an admission for an item the supplied text never names is one the researcher did not supply. A disposition's state must stand as a - whole word, in any case, on a line of the supplied text that names its item, - and an admission, which writes an acceptance, needs that line to admit or - accept the proposal: the state is the ruling, and a state another item's line - carries is not the researcher's answer to this one. A ground, an exit + whole word, in any case, in its item's part of a line of the supplied text + (the whole line when it names no other item), and an admission, which writes + an acceptance, needs that part to admit or accept the proposal: the state is + the ruling, and a state another item's line or part carries is not the + researcher's answer to this one. A ground, an exit condition or a surprise that does not stand verbatim in the supplied text, once whitespace is folded, is one the researcher did not write. The scribe reformats; the check is that every word it carries was already there. @@ -169,6 +170,15 @@ renders what landed first. states carries both, so the verb refuses a state the item's line does not carry and cannot tell which of two it carries the researcher meant. The definition holds the scribe to the ruling the line gives. +- A line ends at LF, CR, CRLF, U+2028 or U+2029. A line that names one item is + that item's whole; on a line that names several, each item owns only the text + from its id to the next item id, so "rdi-2: accepted, unlike rdi-1" accepts + rdi-2 and gives rdi-1 nothing. The split is by position, not by sense. A + ruling shared across ids ("rdi-1 and rdi-2: both accepted") reaches only the + id it follows, and one written ahead of every id on the line reaches none, so + either is refused for the item it misses. A ruling that follows an item named + only in passing ("rdi-2, like rdi-1, accepted") is granted to the item it + follows, rdi-1, and refused for rdi-2. ## References diff --git a/commands/scribe.md b/commands/scribe.md index 1472cb8bd..b5b717de0 100644 --- a/commands/scribe.md +++ b/commands/scribe.md @@ -107,9 +107,10 @@ hold, and any failure exits 2 naming the field and the item: - **Nothing is authored**: a key outside the shapes above (a `resolution`, a `pattern`, a `position`, anything) is refused by name; a disposition or an admission for an item the supplied dispositions never name is refused; a - `state` that does not stand as a whole word on a line naming its item is - refused, and so is an admission whose item's line neither admits nor accepts - it; a `grounds`, an `exit_condition` or a surprise `text` that does not stand + `state` that does not stand as a whole word in its item's part of a line + (the whole line when the line names no other item, else the text from the + item's id to the next item id) is refused, and so is an admission whose + item's part neither admits nor accepts it; a `grounds`, an `exit_condition` or a surprise `text` that does not stand verbatim in the supplied text once whitespace is folded is refused. The scribe reformats; it never adds a word. - **Every answer is the run's**: a disposition, admission or outstanding item diff --git a/internal/core/scribe/ingest.go b/internal/core/scribe/ingest.go index 983b24d38..cc6d2d8b5 100644 --- a/internal/core/scribe/ingest.go +++ b/internal/core/scribe/ingest.go @@ -625,21 +625,87 @@ var admissionTokens = []string{issueschema.DispositionAccepted, "admit", "admits // // A line ends at any terminator a researcher's editor writes (lineBreak): split // on LF alone, a text whose lines end in CR or a Unicode separator is one line, -// and the per-line check collapses to a whole-text one. +// and the per-line check collapses to a whole-text one. Within a line, the token +// must sit in the item's own part of it (itemParts), so a line that names two +// items does not grant one item's ruling to both. func lineCarries(supplied, id string, tokens ...string) bool { for _, line := range strings.FieldsFunc(supplied, lineBreak) { - if !mentions(line, id) { - continue - } - for _, tok := range tokens { - if tok != "" && wholeWord(line, tok) { - return true + for _, part := range itemParts(line, id) { + for _, tok := range tokens { + if tok != "" && wholeWord(part, tok) { + return true + } } } } return false } +// itemParts is the text of line that belongs to id. A line that names id and no +// other item is the item's own, whole, so a ruling written ahead of the id still +// counts for it. On a line that names more than one item, each mention of id +// owns only the text from its end to the next item id the line names: the +// ruling a line gives one item is not granted to another it mentions in passing +// ("rdi-2: accepted, unlike rdi-1" accepts rdi-2 and gives rdi-1 nothing), and a +// ruling written ahead of every id on such a line belongs to none of them, which +// refuses rather than guesses. A line that does not name id gives it nothing. +func itemParts(line, id string) []string { + spans := itemIDs(line) + own, other := false, false + for _, m := range spans { + if line[m[0]:m[1]] == id { + own = true + } else { + other = true + } + } + switch { + case !own: + return nil + case !other: + return []string{line} + } + var parts []string + for i, m := range spans { + if line[m[0]:m[1]] != id { + continue + } + end := len(line) + if i+1 < len(spans) { + end = spans[i+1][0] + } + parts = append(parts, line[m[1]:end]) + } + return parts +} + +// itemIDPattern matches a reading-item id; itemIDs keeps the matches that stand +// as whole tokens. +var itemIDPattern = regexp.MustCompile(regexp.QuoteMeta(issueschema.ReadingItemFamily) + `-[0-9]+`) + +// itemIDs lists, in order, the byte spans of every reading-item id line names as +// a whole token, by the boundary mentions applies: no letter, digit or hyphen +// before it, and no letter or digit after it. +func itemIDs(line string) [][]int { + var out [][]int + for _, m := range itemIDPattern.FindAllStringIndex(line, -1) { + if m[0] > 0 && idByte(line[m[0]-1], true) { + continue + } + if m[1] < len(line) && idByte(line[m[1]], false) { + continue + } + out = append(out, m) + } + return out +} + +// idByte reports whether c continues an id token: an ASCII letter or digit, or, +// where hyphen is set, a hyphen. +func idByte(c byte, hyphen bool) bool { + return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || hyphen && c == '-' +} + // lineBreak reports whether r ends a line: LF, CR (so CRLF too, the empty field // between them dropped), U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR. // The code points are written as numbers so no layer between the author and diff --git a/internal/core/scribe/ingest_test.go b/internal/core/scribe/ingest_test.go index bf62a1ddc..dec090c83 100644 --- a/internal/core/scribe/ingest_test.go +++ b/internal/core/scribe/ingest_test.go @@ -703,3 +703,58 @@ func TestScribeIngestHoldsTheStateToTheItemsLineWhateverEndsIt(t *testing.T) { }) } } + +// TestScribeIngestHoldsTheStateToTheItemsPartOfALine: a line that names two +// items gives each only its own part — the text from its id to the next item id +// — so the state one item's part carries is not granted to the other item the +// line mentions in passing (iss-2609261205178776). +func TestScribeIngestHoldsTheStateToTheItemsPartOfALine(t *testing.T) { + supplied := "{0}: rejected — " + groundA + "." + termLF + + "{1}: accepted — " + groundA + " (unlike {0})." + termLF + s := assembleSession(t, positionDetection, 2, supplied) + before := s.ledger(t) + o := s.out() + o.Dispositions = []OutDisposition{ + {Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}, + {Item: s.items[1], State: issueschema.DispositionAccepted, Grounds: groundA}, + } + _, err := s.ingest(t, s.write(t, o)) + if err == nil || !strings.Contains(err.Error(), "state") || !strings.Contains(err.Error(), s.items[0]) { + t.Fatalf("a state another item's part of a line carries was granted to the item it mentions: %v", err) + } + if s.ledger(t) != before { + t.Fatal("a refused payload changed the ledger") + } + + // The same line still gives the item it opens with its ruling. + s2 := assembleSession(t, positionDetection, 2, supplied) + o2 := s2.out() + o2.Dispositions = []OutDisposition{ + {Item: s2.items[0], State: issueschema.DispositionRejected, Grounds: groundA}, + {Item: s2.items[1], State: issueschema.DispositionAccepted, Grounds: groundA}, + } + if _, err := s2.ingest(t, s2.write(t, o2)); err != nil { + t.Fatalf("the ruling each item's own part carries was refused: %v", err) + } + + // An admission is held by the same rule. + s3 := assembleSession(t, issueschema.PositionWidening, 2, + "{0}: declined — "+groundA+"."+termLF+"{1}: admit it — "+groundA+" (not {0})."+termLF) + writeFile(t, s3.repo, filepath.Join(issueschema.ReadingsRecordDir, "rdg-2609250000000009", issueschema.RunRecordFileName), + `{"run_id":"rdg-2609250000000009","position":"comparative","candidate_run":"`+fixtureRun+`"}`) + o3 := s3.out() + o3.Admissions = []OutAdmission{{Item: s3.items[0], Grounds: groundA}, {Item: s3.items[1], Grounds: groundA}} + if _, err := s3.ingest(t, s3.write(t, o3)); err == nil || !strings.Contains(err.Error(), "admission") || + !strings.Contains(err.Error(), s3.items[0]) { + t.Fatalf("an admission another item's part of a line carries was granted to the item it mentions: %v", err) + } + + // A line that names one item is read whole, so a ruling written ahead of + // the id still counts for it. + s4 := assembleSession(t, positionDetection, 1, "Accepted: {0} — "+groundA+"."+termLF) + o4 := s4.out() + o4.Dispositions = []OutDisposition{{Item: s4.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}} + if _, err := s4.ingest(t, s4.write(t, o4)); err != nil { + t.Fatalf("a ruling ahead of the id on a line naming one item was refused: %v", err) + } +} From 3edb36830e49d5f57e63ca94efcb83d374b86f41 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:11:29 +0100 Subject: [PATCH 65/78] fix(scribe): refuse a symlinked readings or run directory before the listing runItems judged the ledger's ancestors through capture.RefuseRedirectedLedger and then listed the run through os.ReadDir, which follows a symlinked leaf, so a link planted at .abcd/work/issues/readings or at the run directory after the assembly passed the ingest's listing. The scribe's wrapper takes the directories below the issues root that a caller lists through and judges each with readingitem.RefuseSymlinkedDir, the primitive capture's own ledger walk uses, so no third copy of the rule exists. Refs: iss-2609261205185463 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/31-scribe.md | 5 ++- internal/core/scribe/assemble.go | 17 +++++++++- internal/core/scribe/ingest.go | 6 ++-- internal/core/scribe/ingest_test.go | 31 +++++++++++++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/31-scribe.md b/.abcd/development/brief/04-surfaces/31-scribe.md index 559065219..5191a4e51 100644 --- a/.abcd/development/brief/04-surfaces/31-scribe.md +++ b/.abcd/development/brief/04-surfaces/31-scribe.md @@ -99,7 +99,10 @@ until all of the following hold: once whitespace is folded, is one the researcher did not write. The scribe reformats; the check is that every word it carries was already there. - **Every answer is the run's, once.** An answered or outstanding item must be one - of the run's items, and one item takes one answer. + of the run's items, and one item takes one answer. The run's items are listed + through the ledger's directories down to the run's own, and a symlink at any + of them, the readings directory and the run directory included, is refused + before the listing, by the rule the assembly applies. - **Nothing is passed over in silence.** Every item of the run with no standing disposition is answered, listed as outstanding, or named in a refusal. An item already answered in the ledger is not owed again, which is what lets a rerun diff --git a/internal/core/scribe/assemble.go b/internal/core/scribe/assemble.go index 867a92334..d3b06c560 100644 --- a/internal/core/scribe/assemble.go +++ b/internal/core/scribe/assemble.go @@ -13,6 +13,7 @@ import ( "github.com/intentdriven/abcd/internal/core/capture" "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/reading" + "github.com/intentdriven/abcd/internal/core/readingitem" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/core/sessionkind" "github.com/intentdriven/abcd/internal/fsutil" @@ -258,11 +259,25 @@ func collectLedger(repoRoot string) ([]LedgerEntry, error) { // refuseRedirectedLedger is capture.RefuseRedirectedLedger under this package's // symlink sentinel, so a caller tests one refusal whichever level held the link. -func refuseRedirectedLedger(repoRoot string) error { +// +// below names directories under the issues root, each one level deeper than the +// last, that a caller lists through: each is judged in turn by +// readingitem.RefuseSymlinkedDir, the primitive capture's own ledger walk judges +// every ledger directory by, so a symlinked readings or run directory is refused +// as a symlinked `.abcd` is. An absent directory is not a fault here either. +func refuseRedirectedLedger(repoRoot string, below ...string) error { if err := capture.RefuseRedirectedLedger(repoRoot); err != nil { return fmt.Errorf("%w: the ledger is reached through a directory that is not a real one (%w); the "+ "context is drawn from the ledger's own directories and a link above them is a route out", ErrSymlink, err) } + dir := filepath.Join(repoRoot, filepath.FromSlash(capture.LedgerRelPath)) + for _, segment := range below { + dir = filepath.Join(dir, segment) + if err := readingitem.RefuseSymlinkedDir(dir); err != nil { + return fmt.Errorf("%w: a ledger directory the scribe lists through is not a real one (%w); a link "+ + "there is a route out of the ledger", ErrSymlink, err) + } + } return nil } diff --git a/internal/core/scribe/ingest.go b/internal/core/scribe/ingest.go index cc6d2d8b5..e8d22979c 100644 --- a/internal/core/scribe/ingest.go +++ b/internal/core/scribe/ingest.go @@ -441,8 +441,10 @@ func refusePromoted(repoRoot, run string) error { // whether a disposition already stands over it. func runItems(repoRoot, run string) (map[string]bool, error) { // The listing is a plain path read, so the directories above it are judged - // first by the same rule the assembly applies. - if err := refuseRedirectedLedger(repoRoot); err != nil { + // first by the same rule the assembly applies, and so are the two it lists + // through, the readings directory and the run's own: os.ReadDir follows a + // symlinked leaf. + if err := refuseRedirectedLedger(repoRoot, issueschema.ReadingsDir, run); err != nil { return nil, err } dir := filepath.Join(repoRoot, filepath.FromSlash(runRecordsDir(run))) diff --git a/internal/core/scribe/ingest_test.go b/internal/core/scribe/ingest_test.go index dec090c83..8b3a2a9a7 100644 --- a/internal/core/scribe/ingest_test.go +++ b/internal/core/scribe/ingest_test.go @@ -758,3 +758,34 @@ func TestScribeIngestHoldsTheStateToTheItemsPartOfALine(t *testing.T) { t.Fatalf("a ruling ahead of the id on a line naming one item was refused: %v", err) } } + +// TestScribeIngestRefusesASymlinkedReadingsOrRunDir: the ingest lists the run's +// items through the readings directory and the run directory, so those two are +// judged as the ledger's ancestors are; a link planted at either after the +// assembly is refused before the listing (iss-2609261205185463). +func TestScribeIngestRefusesASymlinkedReadingsOrRunDir(t *testing.T) { + readings := capture.LedgerRelPath + "/" + issueschema.ReadingsDir + for name, tc := range map[string]struct{ rel, target string }{ + "readings": {readings, "../../../docs/shadow"}, + "run": {readings + "/" + fixtureRun, "../../../../docs/shadow"}, + } { + t.Run(name, func(t *testing.T) { + s := assembleSession(t, positionDetection, 1, "{0}: accepted — "+groundA+"."+termLF) + linked := filepath.Join(s.repo, filepath.FromSlash(tc.rel)) + if err := os.Rename(linked, filepath.Join(s.repo, "docs", "shadow")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(tc.target, linked); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if _, err := os.ReadDir(linked); err != nil { + t.Fatalf("the planted link does not resolve, so the probe proves nothing: %v", err) + } + o := s.out() + o.Outstanding = []string{s.items[0]} + if _, err := s.ingest(t, s.write(t, o)); !errors.Is(err, ErrSymlink) { + t.Fatalf("an ingest listing through a symlinked %s directory was not refused: %v", name, err) + } + }) + } +} From 2bcce248f8dcae1388b70c5232904a953bb42a80 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:11:42 +0100 Subject: [PATCH 66/78] =?UTF-8?q?chore:=20resolve=20iss-2609261205176571?= =?UTF-8?q?=20=E2=80=94=20the=20state=20check's=20lines=20end=20at=20every?= =?UTF-8?q?=20terminator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261205176571 Assisted-by: Claude:claude-opus-5-5 --- ...e-ingest-s-per-line-state-check-splits-the-supplied.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261205176571-scribe-ingest-s-per-line-state-check-splits-the-supplied.md (61%) diff --git a/.abcd/work/issues/open/iss-2609261205176571-scribe-ingest-s-per-line-state-check-splits-the-supplied.md b/.abcd/work/issues/resolved/iss-2609261205176571-scribe-ingest-s-per-line-state-check-splits-the-supplied.md similarity index 61% rename from .abcd/work/issues/open/iss-2609261205176571-scribe-ingest-s-per-line-state-check-splits-the-supplied.md rename to .abcd/work/issues/resolved/iss-2609261205176571-scribe-ingest-s-per-line-state-check-splits-the-supplied.md index ca5274ad3..1ddb44983 100644 --- a/.abcd/work/issues/open/iss-2609261205176571-scribe-ingest-s-per-line-state-check-splits-the-supplied.md +++ b/.abcd/work/issues/resolved/iss-2609261205176571-scribe-ingest-s-per-line-state-check-splits-the-supplied.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review2-scribe" origin: researcher-authored production_mode: hand-written found_at: "internal/core/scribe/ingest.go" +resolution: "lineCarries ends a line at LF, CR, CRLF, U+2028 and U+2029, so a CR- or separator-terminated dispositions text is judged line by line" +impact: fix +resolved_by: + commit: "118b46a2" --- scribe ingest's per-line state check splits the supplied dispositions on LF alone, so a text whose lines end in CR, U+2028 or U+2029 is one line and the check collapses to a whole-text test: '{0}: rejected ... CR {1}: accepted ... CR' wrote {0} accepted (probed by review2-scribe; CRLF is unaffected). lineCarries in internal/core/scribe/ingest.go must split on CRLF, CR, LF, U+2028 and U+2029. + +## Grounds + +- pursued: TestScribeIngestHoldsTheStateToTheItemsLineWhateverEndsIt refuses a state carried only by the next line under every terminator and lands the rulings the lines give; a CR, U+2028 or U+2029 subtest writing the next line's state would show it wrong From 1f70f120725f3af433b3fe71274612e2b73bb83e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:11:44 +0100 Subject: [PATCH 67/78] =?UTF-8?q?chore:=20resolve=20iss-2609261205178776?= =?UTF-8?q?=20=E2=80=94=20a=20state=20is=20held=20to=20the=20item's=20part?= =?UTF-8?q?=20of=20a=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261205178776 Assisted-by: Claude:claude-opus-5-5 --- ...ingest-s-state-check-grants-a-state-to-every-item-a.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261205178776-scribe-ingest-s-state-check-grants-a-state-to-every-item-a.md (58%) diff --git a/.abcd/work/issues/open/iss-2609261205178776-scribe-ingest-s-state-check-grants-a-state-to-every-item-a.md b/.abcd/work/issues/resolved/iss-2609261205178776-scribe-ingest-s-state-check-grants-a-state-to-every-item-a.md similarity index 58% rename from .abcd/work/issues/open/iss-2609261205178776-scribe-ingest-s-state-check-grants-a-state-to-every-item-a.md rename to .abcd/work/issues/resolved/iss-2609261205178776-scribe-ingest-s-state-check-grants-a-state-to-every-item-a.md index 39ae0334f..68fa29828 100644 --- a/.abcd/work/issues/open/iss-2609261205178776-scribe-ingest-s-state-check-grants-a-state-to-every-item-a.md +++ b/.abcd/work/issues/resolved/iss-2609261205178776-scribe-ingest-s-state-check-grants-a-state-to-every-item-a.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review2-scribe" origin: researcher-authored production_mode: hand-written found_at: "internal/core/scribe/ingest.go" +resolution: "a line naming several items gives each only the text from its id to the next item id, so one item's ruling is not granted to another the line mentions; the positional residue is disclosed in 31-scribe.md and commands/scribe.md" +impact: fix +resolved_by: + commit: "e8c12571" --- scribe ingest's state check grants a state to every item a line names: lineCarries in internal/core/scribe/ingest.go reads the whole line that mentions the item, so a line such as 'rdi-2: accepted, unlike rdi-1' carries 'accepted' for rdi-1 too, and a payload filing rdi-1 as accepted passes the check the researcher's own line contradicts (probed by review2-scribe: {1}: accepted ... (unlike {0}) wrote {0} accepted). The state must be held to the part of the line that belongs to the item. + +## Grounds + +- pursued: TestScribeIngestHoldsTheStateToTheItemsPartOfALine refuses rdi-1 accepted from 'rdi-2: accepted (unlike rdi-1)', lands each item's own ruling, holds an admission the same way, and keeps a single-item line whole; a payload granting the mentioned item the other's state landing would show it wrong From de6796a780fbdeb248f75158d853a8d514a6831a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:11:46 +0100 Subject: [PATCH 68/78] =?UTF-8?q?chore:=20resolve=20iss-2609261205185463?= =?UTF-8?q?=20=E2=80=94=20the=20ingest=20refuses=20a=20symlinked=20reading?= =?UTF-8?q?s=20or=20run=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261205185463 Assisted-by: Claude:claude-opus-5-5 --- ...scribe-ingest-lists-a-run-s-reading-items-through-a.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261205185463-scribe-ingest-lists-a-run-s-reading-items-through-a.md (67%) diff --git a/.abcd/work/issues/open/iss-2609261205185463-scribe-ingest-lists-a-run-s-reading-items-through-a.md b/.abcd/work/issues/resolved/iss-2609261205185463-scribe-ingest-lists-a-run-s-reading-items-through-a.md similarity index 67% rename from .abcd/work/issues/open/iss-2609261205185463-scribe-ingest-lists-a-run-s-reading-items-through-a.md rename to .abcd/work/issues/resolved/iss-2609261205185463-scribe-ingest-lists-a-run-s-reading-items-through-a.md index 57f70c969..51345bed0 100644 --- a/.abcd/work/issues/open/iss-2609261205185463-scribe-ingest-lists-a-run-s-reading-items-through-a.md +++ b/.abcd/work/issues/resolved/iss-2609261205185463-scribe-ingest-lists-a-run-s-reading-items-through-a.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review2-scribe" origin: researcher-authored production_mode: hand-written found_at: "internal/core/scribe/ingest.go" +resolution: "scribe ingest judges the readings directory and the run directory with readingitem.RefuseSymlinkedDir before listing, after capture.RefuseRedirectedLedger judges the ancestors" +impact: fix +resolved_by: + commit: "3edb3683" --- scribe ingest lists a run's reading items through a directory whose own link status it never judges: runItems in internal/core/scribe/ingest.go refuses a symlinked .abcd, .abcd/work, issues root or status directory through capture.RefuseRedirectedLedger, but a symlinked .abcd/work/issues/readings or run directory is followed by os.ReadDir, so the listing is drawn from outside the ledger (probed by review2-scribe: nil) where scribe assemble refuses the same link. The readings directory and the run directory are to be Lstat-refused by the primitive capture's own ledger walk uses. + +## Grounds + +- pursued: TestScribeIngestRefusesASymlinkedReadingsOrRunDir plants a resolving link at each directory after assembly and expects ErrSymlink; an ingest that lists through either link would show it wrong From a2a2853335be821ab154808d86810de206160f5e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:11:55 +0100 Subject: [PATCH 69/78] chore: re-anchor iss-2609261036363114's deferral on v0.11.0 The v0.10.0 grant lapsed when v0.11.0 was cut: GuardFindings holds deferred_after to the cut's anchor tag. The reason stands unchanged. Refs: iss-2609261036363114 Assisted-by: Claude:claude-opus-5-5 --- ...cribe-ingest-decodes-its-payload-with-plain-encoding-json.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.abcd/work/issues/open/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md b/.abcd/work/issues/open/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md index 745a8daa3..5931e1b2c 100644 --- a/.abcd/work/issues/open/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md +++ b/.abcd/work/issues/open/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md @@ -9,7 +9,7 @@ found_during: "autonomous run A resumed 2026-09-25: review-scribe" origin: researcher-authored production_mode: hand-written found_at: "internal/core/scribe/ingest.go" -deferred_after: "v0.10.0" +deferred_after: "v0.11.0" deferral_reason: "deferred to the integration step (run A, 2026-09-26): the strict duplicate-key decoder jsonstrict lives on the unmerged lintB lane and copying it here would fork it; once lintB lands, decodeOutput in internal/core/scribe/ingest.go reroutes its decode through jsonstrict at every depth, refusing duplicate keys and case-folded twins, and this record is resolved there. Until then the state a duplicate key selects is still held to the item's own line of the supplied dispositions, and every free text to the supplied text verbatim" --- From afd302188587584c6fe90f3143ca523361e73a0f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:26:11 +0100 Subject: [PATCH 70/78] chore: capture the reframe whole write's rebase reading (review2-reframe OBS-A) A multi-commit rebase of a rewrite gives capture reframe a different changed set than a --no-ff merge or a squash of the same rewrite. Owed at landing by review2-reframe; captured, not decided: it needs a line on the page or a spec ruling. Refs: iss-2609261325441711 Assisted-by: Claude:claude-opus-5-5 --- ...me-s-whole-write-reads-a-multi-commit-rebase.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609261325441711-capture-reframe-s-whole-write-reads-a-multi-commit-rebase.md diff --git a/.abcd/work/issues/open/iss-2609261325441711-capture-reframe-s-whole-write-reads-a-multi-commit-rebase.md b/.abcd/work/issues/open/iss-2609261325441711-capture-reframe-s-whole-write-reads-a-multi-commit-rebase.md new file mode 100644 index 000000000..f18c6c0e3 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261325441711-capture-reframe-s-whole-write-reads-a-multi-commit-rebase.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609261325441711" +slug: "capture-reframe-s-whole-write-reads-a-multi-commit-rebase" +severity: "minor" +category: "inconsistency" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: integ3, review2-reframe OBS-A" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/capture/reframe.go" +--- + +capture reframe's whole write reads a multi-commit rebase differently from a --no-ff merge or a squash of the same rewrite: two rebased commits that move the construal and then the glossary give changed=[glossary] with before at the post-construal state, where --no-ff and squash give changed=[construal glossary]. The first-differing-triple rule of spc-2609020626048705 produces it, and commands/capture.md and brief 06-capture.md claim only squash equivalence. Either a line on the page naming the rebase case or a spec ruling on what previous distinct state means across a rebased series; not decided here (review2-reframe OBS-A). From 1296a10232b626456968c83e1ef64310129b65ff Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:26:13 +0100 Subject: [PATCH 71/78] docs(capture): a reframe body stripped of its signals is plain prose to the lint review2-reframe OBS-B, owed at landing: cross_store_id_claim detects a reframe outside its store by its three signals, so a copy with all of them stripped passes as prose. Inherent to signal-based detection; the chapter names the limit in one sentence. Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/brief/04-surfaces/06-capture.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index a7961bffe..7e0cddc9d 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -224,7 +224,10 @@ history reaches), a second open record, a completion in which nothing moved, and before state the history no longer holds within 64 commits touching the frame. The readings keep the record out by its store's path, so record-lint's `cross_store_id_claim` refuses a reframe-shaped file (an `rfm-N` name, an -`rfm-N` id, or `occasioned_by` beside a before fingerprint) anywhere else. +`rfm-N` id, or `occasioned_by` beside a before fingerprint) anywhere else. A +reframe's text copied out with all three signals stripped is plain prose to +that check and reaches a reading like any other prose, a limit of detecting +the record by its signals. **Resolving** marks an issue resolved and moves it to `resolved/`. Impact is required, and resolving without it is refused with From d791f545c8b09801c00e31511f87f3b45c3eb598 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:41:43 +0100 Subject: [PATCH 72/78] docs(brief): the scribe chapter takes surface row 32 The lab lane keeps row 31 (04-surfaces/31-lab.md) and lands first, so the scribe chapter moves to the next free row: 32-scribe.md, its index row, the agents chapter's link and the release-gate manifest's briefDocs entry. The manifest's checkerCount (41) and promptHash are unchanged by the rename; the lab's +1 and its roster word combine with them when main is merged again. Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/{31-scribe.md => 32-scribe.md} | 0 .abcd/development/brief/04-surfaces/README.md | 2 +- .abcd/development/brief/05-internals/01-agents.md | 2 +- .abcd/development/release-gate/manifest.json | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename .abcd/development/brief/04-surfaces/{31-scribe.md => 32-scribe.md} (100%) diff --git a/.abcd/development/brief/04-surfaces/31-scribe.md b/.abcd/development/brief/04-surfaces/32-scribe.md similarity index 100% rename from .abcd/development/brief/04-surfaces/31-scribe.md rename to .abcd/development/brief/04-surfaces/32-scribe.md diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index 681ae4493..966e977aa 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -43,7 +43,7 @@ are wiring rather than user-facing surface are listed separately under | 28 | `/abcd:peers` | shipped | See what the sibling worktrees and local branches hold before capturing, fixing or filing anything | [`08-abcd.md`](08-abcd.md) | | 29 | `/abcd:report` | shipped | Tell abcd about a defect or propose an enhancement from a repository it manages, into an inbox in your own account | [`29-report.md`](29-report.md) | | 30 | `/abcd:inbox` | shipped | Read the reports managed repositories filed, and promote one to a capture that names the sender only by its root-commit key | [`30-inbox.md`](30-inbox.md) | -| 31 | `/abcd:scribe` | shipped | Build the ledger scribe's context from the ledger alone, and ingest what it transcribed without letting it author anything | [`31-scribe.md`](31-scribe.md) | +| 32 | `/abcd:scribe` | shipped | Build the ledger scribe's context from the ledger alone, and ingest what it transcribed without letting it author anything | [`32-scribe.md`](32-scribe.md) | ## How much of this table a machine keeps honest diff --git a/.abcd/development/brief/05-internals/01-agents.md b/.abcd/development/brief/05-internals/01-agents.md index 01c8f74ff..b7fb97947 100644 --- a/.abcd/development/brief/05-internals/01-agents.md +++ b/.abcd/development/brief/05-internals/01-agents.md @@ -190,7 +190,7 @@ definition names the right paths, not that a host assembled the right context. Mechanical assembly belongs to `abcd scribe assemble`, which builds the context from an allow list derived from the ledger's own directory list and parks it with a manifest of every path passed; a third test holds the definition's list to -that function ([`04-surfaces/31-scribe.md`](../04-surfaces/31-scribe.md)). +that function ([`04-surfaces/32-scribe.md`](../04-surfaces/32-scribe.md)). The mechanical path exists beside the scribe: `abcd reading ingest` validates the output a reading returned and writes its reading records, `abcd capture diff --git a/.abcd/development/release-gate/manifest.json b/.abcd/development/release-gate/manifest.json index 93c22122a..91bf2fa5b 100644 --- a/.abcd/development/release-gate/manifest.json +++ b/.abcd/development/release-gate/manifest.json @@ -46,7 +46,7 @@ ".abcd/development/brief/04-surfaces/27-implement.md", ".abcd/development/brief/04-surfaces/29-report.md", ".abcd/development/brief/04-surfaces/30-inbox.md", - ".abcd/development/brief/04-surfaces/31-scribe.md", + ".abcd/development/brief/04-surfaces/32-scribe.md", ".abcd/development/brief/04-surfaces/README.md", ".abcd/development/brief/02-constraints/04-naming.md", ".abcd/development/brief/05-internals/01-agents.md", From 4228e4f9df56ed7c8e07575a2742acce5c2ed142 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:50:30 +0100 Subject: [PATCH 73/78] chore: recalibrate the reading windows at the integration tip Measured by dry-run assembly on a clean clone of 93082a92 (the tip after admission, reframe, scribe and principles on main 811fba17), and each window set to ceil(tokens * 1.01 / 10000) * 10000: - widening: 1105127 tokens, 4254739 bytes -> 1120000 (was 1060000) - entailment: 351956 tokens, 1355031 bytes -> 360000 (was 350000) - detection: 1114162 tokens, 4289527 bytes -> 1130000 (was 1060000) Comparative is untouched (its window is measured against the fixture run, not the tree). Refs: iss-2609251455354719 Assisted-by: Claude:claude-opus-5-5 --- .abcd/config/reading-presets.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.abcd/config/reading-presets.json b/.abcd/config/reading-presets.json index 0d38242bc..4cc71419d 100644 --- a/.abcd/config/reading-presets.json +++ b/.abcd/config/reading-presets.json @@ -60,10 +60,10 @@ "test" ], "window": { - "tokens_est": 1060000, - "measured_tokens_est": 1040187, - "measured_bytes": 4004721, - "measured_at": "68deae03f210499a62d356dc67101b268c15caed" + "tokens_est": 1120000, + "measured_tokens_est": 1105127, + "measured_bytes": 4254739, + "measured_at": "93082a92cc4ebf14a4b46a3c350564aa885ad9e7" } }, "entailment": { @@ -132,10 +132,10 @@ "intent-projection" ], "window": { - "tokens_est": 350000, - "measured_tokens_est": 344124, - "measured_bytes": 1324879, - "measured_at": "68deae03f210499a62d356dc67101b268c15caed" + "tokens_est": 360000, + "measured_tokens_est": 351956, + "measured_bytes": 1355031, + "measured_at": "93082a92cc4ebf14a4b46a3c350564aa885ad9e7" } }, "comparative": { @@ -216,10 +216,10 @@ "test" ], "window": { - "tokens_est": 1060000, - "measured_tokens_est": 1049223, - "measured_bytes": 4039509, - "measured_at": "68deae03f210499a62d356dc67101b268c15caed" + "tokens_est": 1130000, + "measured_tokens_est": 1114162, + "measured_bytes": 4289527, + "measured_at": "93082a92cc4ebf14a4b46a3c350564aa885ad9e7" } } } From 4a51e87238921d20499fe8fb937bbbfb4e2810b4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:00:48 +0100 Subject: [PATCH 74/78] fix(scribe): refuse a repeated key in the scribe payload rather than reading it last-wins decodeStrict now runs jsonstrict.NoDuplicateKeys before it decodes, so the scribe output, the manifest and the context all refuse a key repeated at any depth. Before this, {"state":"declined","state":"accepted"} decoded as an accepted disposition. jsonstrict reached main with #726. The scribe page and the brief chapter name the refusal. TestScribeIngestRefusesARepeatedKey was watched RED on a scratch copy of the merged tree: both cases, a repeated state and a repeated run, were read last-wins and landed. It is GREEN with this change. Refs: iss-2609261036363114 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/32-scribe.md | 3 +- commands/scribe.md | 3 +- internal/core/scribe/dupkey_test.go | 34 +++++++++++++++++++ internal/core/scribe/scribe.go | 12 +++++-- 4 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 internal/core/scribe/dupkey_test.go diff --git a/.abcd/development/brief/04-surfaces/32-scribe.md b/.abcd/development/brief/04-surfaces/32-scribe.md index 5191a4e51..511a5ad93 100644 --- a/.abcd/development/brief/04-surfaces/32-scribe.md +++ b/.abcd/development/brief/04-surfaces/32-scribe.md @@ -88,7 +88,8 @@ until all of the following hold: below reads that text. - **Nothing is authored.** The payload is decoded against closed shapes at every level, so a key the scribe may not author is refused by name with the entry it - sat on. A disposition or an admission for an item the supplied text never names + sat on, and a key repeated at any level is refused rather than read last-wins. + A disposition or an admission for an item the supplied text never names is one the researcher did not supply. A disposition's state must stand as a whole word, in any case, in its item's part of a line of the supplied text (the whole line when it names no other item), and an admission, which writes diff --git a/commands/scribe.md b/commands/scribe.md index c63eece65..df9bc7a0d 100644 --- a/commands/scribe.md +++ b/commands/scribe.md @@ -106,7 +106,8 @@ hold, and any failure exits 2 naming the field and the item: `--dispositions` names, and every check below reads that file. Never hand the scribe session that file's path. - **Nothing is authored**: a key outside the shapes above (a `resolution`, a - `pattern`, a `position`, anything) is refused by name; a disposition or an + `pattern`, a `position`, anything) is refused by name, and so is a key the + payload repeats at any depth, which is never read last-wins; a disposition or an admission for an item the supplied dispositions never name is refused; a `state` that does not stand as a whole word in its item's part of a line (the whole line when the line names no other item, else the text from the diff --git a/internal/core/scribe/dupkey_test.go b/internal/core/scribe/dupkey_test.go new file mode 100644 index 000000000..57b28e448 --- /dev/null +++ b/internal/core/scribe/dupkey_test.go @@ -0,0 +1,34 @@ +package scribe + +import ( + "strings" + "testing" +) + +// TestScribeIngestRefusesARepeatedKey: a key the payload repeats, at any depth, +// is refused rather than read last-wins, so {"state":"declined","state":"accepted"} +// is not an accepted disposition, and nothing lands. The decode goes through +// jsonstrict, the one strict-JSON check every trust-boundary reader shares +// (iss-2609261036363114). +func TestScribeIngestRefusesARepeatedKey(t *testing.T) { + for _, tc := range []struct{ name, run, state string }{ + {"a repeated state on a disposition", `"run":"` + fixtureRun + `"`, `"state":"declined","state":"accepted"`}, + {"a repeated run at the top", `"run":"rdg-1","run":"` + fixtureRun + `"`, `"state":"accepted"`}, + } { + t.Run(tc.name, func(t *testing.T) { + // A session of its own each, so a case read last-wins cannot make the + // next one fail for another reason. + s := assembleSession(t, positionDetection, 1, "{0}: accepted — "+groundA+".\n") + before := s.ledger(t) + raw := `{"_type":"` + OutputType + `","context_sha256":"` + s.res.ContextSHA256 + `",` + tc.run + + `,"dispositions":[{"item":"` + s.items[0] + `",` + tc.state + `,"grounds":"` + groundA + `"}]}` + _, err := s.ingest(t, s.writeRaw(t, raw)) + if err == nil || !strings.Contains(err.Error(), "duplicate key") { + t.Fatalf("a repeated key was read last-wins rather than refused: %v", err) + } + if s.ledger(t) != before { + t.Fatal("a refused payload changed the ledger") + } + }) + } +} diff --git a/internal/core/scribe/scribe.go b/internal/core/scribe/scribe.go index f16d0228d..7999699ee 100644 --- a/internal/core/scribe/scribe.go +++ b/internal/core/scribe/scribe.go @@ -39,6 +39,7 @@ import ( "github.com/intentdriven/abcd/internal/core/capture" "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/jsonstrict" "github.com/intentdriven/abcd/internal/termsafe" ) @@ -181,9 +182,16 @@ func encode(v any) ([]byte, error) { return buf.Bytes(), nil } -// decodeStrict decodes one document, refusing unknown fields and trailing -// content. +// decodeStrict decodes one document, refusing a repeated key at any depth, +// unknown fields and trailing content. The scribe output, the manifest and the +// context all decode through it. func decodeStrict(data []byte, into any, what string) error { + // A repeated key at any depth is refused, not read last-wins: encoding/json + // would take {"state":"declined","state":"accepted"} as accepted. jsonstrict + // is the one check every trust-boundary reader shares (iss-2609261036363114). + if err := jsonstrict.NoDuplicateKeys(data); err != nil { + return fmt.Errorf("scribe: decoding %s: %w; nothing is written", what, err) + } dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() if err := dec.Decode(into); err != nil { From 07381a3456c754c093ee8fdbcafd33222c2ea445 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:01:06 +0100 Subject: [PATCH 75/78] =?UTF-8?q?chore:=20resolve=20iss-2609261036363114?= =?UTF-8?q?=20=E2=80=94=20the=20scribe=20payload=20refuses=20a=20repeated?= =?UTF-8?q?=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261036363114 Assisted-by: Claude:claude-opus-5-5 --- ...ingest-decodes-its-payload-with-plain-encoding-json.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md (70%) diff --git a/.abcd/work/issues/open/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md b/.abcd/work/issues/resolved/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md similarity index 70% rename from .abcd/work/issues/open/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md rename to .abcd/work/issues/resolved/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md index 5931e1b2c..a0dee6bd9 100644 --- a/.abcd/work/issues/open/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md +++ b/.abcd/work/issues/resolved/iss-2609261036363114-scribe-ingest-decodes-its-payload-with-plain-encoding-json.md @@ -11,6 +11,14 @@ production_mode: hand-written found_at: "internal/core/scribe/ingest.go" deferred_after: "v0.11.0" deferral_reason: "deferred to the integration step (run A, 2026-09-26): the strict duplicate-key decoder jsonstrict lives on the unmerged lintB lane and copying it here would fork it; once lintB lands, decodeOutput in internal/core/scribe/ingest.go reroutes its decode through jsonstrict at every depth, refusing duplicate keys and case-folded twins, and this record is resolved there. Until then the state a duplicate key selects is still held to the item's own line of the supplied dispositions, and every free text to the supplied text verbatim" +resolution: "scribe's decodeStrict runs jsonstrict.NoDuplicateKeys before it decodes, so the scribe output, manifest and context refuse a key repeated at any depth instead of reading it last-wins" +impact: fix +resolved_by: + commit: "4a51e87238921d20499fe8fb937bbbfb4e2810b4" --- scribe ingest decodes its payload with plain encoding/json, so a duplicate key at any depth takes the last value: {"state":"rejected","state":"accepted"} decodes as accepted rather than being refused + +## Grounds + +- pursued: a payload repeating state or run at any depth is refused before anything lands; a repeated key that still decodes last-wins and writes a record would show it wrong From dbf21939341e47a9cc7869c6a89c325bb81701a6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:01:25 +0100 Subject: [PATCH 76/78] test(capture): pin the status line's open folder to capture's own constants statusline reads recordid.IssuesRelDir and issueschema.StatusDirs[0] because importing capture there is a cycle. This test pins those values to capture.LedgerRelPath and to StateOpen, StateResolved and StateWontfix, in order. It closes the LOW that review-integ3 left owed. On a scratch copy the test was watched fail with StateOpen changed to "opened". Assisted-by: Claude:claude-opus-5-5 --- .../core/capture/statusline_constants_test.go | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 internal/core/capture/statusline_constants_test.go diff --git a/internal/core/capture/statusline_constants_test.go b/internal/core/capture/statusline_constants_test.go new file mode 100644 index 000000000..f15aadf21 --- /dev/null +++ b/internal/core/capture/statusline_constants_test.go @@ -0,0 +1,28 @@ +package capture + +import ( + "testing" + + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/recordid" +) + +// statusline counts open issues by reading recordid.IssuesRelDir and +// issueschema.StatusDirs[0], not this package's constants, because importing +// capture from statusline is a cycle. This test pins the two spellings together, +// so the folder the status line counts is the folder capture writes an open +// record into. The other status folders are pinned beside it. +func TestStatuslineReadsTheFolderCaptureWrites(t *testing.T) { + if LedgerRelPath != recordid.IssuesRelDir { + t.Errorf("LedgerRelPath %q != recordid.IssuesRelDir %q", LedgerRelPath, recordid.IssuesRelDir) + } + want := []State{StateOpen, StateResolved, StateWontfix} + if len(issueschema.StatusDirs) != len(want) { + t.Fatalf("issueschema.StatusDirs = %q, want the %d states %q in order", issueschema.StatusDirs, len(want), want) + } + for i, s := range want { + if string(s) != issueschema.StatusDirs[i] { + t.Errorf("issueschema.StatusDirs[%d] = %q, want %q", i, issueschema.StatusDirs[i], s) + } + } +} From d36b5b988ff28b6392861f25f9638488a155566d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:05:58 +0100 Subject: [PATCH 77/78] chore: recalibrate the reading windows at the re-merged integration tip A clean clone of dbf21939 (main #726 merged in, with the scribe, capture-pin and guarded-read fixes) was measured by dry-run assemble. Each window is ceil(tokens * 1.01 / 10000) * 10000. An existing window is kept when it still has 1% headroom. - widening: 1179078 tokens / 4539453 bytes, window 1130000 -> 1200000 - entailment: 363669 tokens / 1400129 bytes, window kept at 370000 (1.7% headroom) - detection: 1188114 tokens / 4574241 bytes, window 1140000 -> 1200000 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 c020ac803..ed3a76376 100644 --- a/.abcd/config/reading-presets.json +++ b/.abcd/config/reading-presets.json @@ -60,10 +60,10 @@ "test" ], "window": { - "tokens_est": 1130000, - "measured_tokens_est": 1115292, - "measured_bytes": 4293878, - "measured_at": "8fd646fa043cd76a9d839b69ac797e1b3de3d5af" + "tokens_est": 1200000, + "measured_tokens_est": 1179078, + "measured_bytes": 4539453, + "measured_at": "dbf21939341e47a9cc7869c6a89c325bb81701a6" } }, "entailment": { @@ -133,9 +133,9 @@ ], "window": { "tokens_est": 370000, - "measured_tokens_est": 357546, - "measured_bytes": 1376554, - "measured_at": "8fd646fa043cd76a9d839b69ac797e1b3de3d5af" + "measured_tokens_est": 363669, + "measured_bytes": 1400129, + "measured_at": "dbf21939341e47a9cc7869c6a89c325bb81701a6" } }, "comparative": { @@ -216,10 +216,10 @@ "test" ], "window": { - "tokens_est": 1140000, - "measured_tokens_est": 1124328, - "measured_bytes": 4328666, - "measured_at": "8fd646fa043cd76a9d839b69ac797e1b3de3d5af" + "tokens_est": 1200000, + "measured_tokens_est": 1188114, + "measured_bytes": 4574241, + "measured_at": "dbf21939341e47a9cc7869c6a89c325bb81701a6" } } } From a4d23d07e1c4bfe70bf7dafe7c503e42f617fe92 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:09:11 +0100 Subject: [PATCH 78/78] docs(record): code-span the example link forms in a resolved principles record Main's links_resolve now reaches .abcd/work (#726), so the literal `[label](target)` in this resolved record's body was read as a link and failed record-lint at the re-merged tip. The three example forms are now inline code. The prose is unchanged. Refs: iss-2609261039139464 Assisted-by: Claude:claude-opus-5-5 --- ...rinciple-statement-s-citation-check-reads-links-as-inline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.abcd/work/issues/resolved/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md b/.abcd/work/issues/resolved/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md index 245873703..2c8fae432 100644 --- a/.abcd/work/issues/resolved/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md +++ b/.abcd/work/issues/resolved/iss-2609261039139464-a-principle-statement-s-citation-check-reads-links-as-inline.md @@ -15,7 +15,7 @@ resolved_by: commit: "a5f5a266662ed258f549a49a4847a391e68fd9f6" --- -A principle statement's citation check reads links as inline [label](target) only: a bare URL, an autolink <https://...> and a reference-style [label][ref] travel into the bundle raw and the lint is silent on all three, while the manifest's exclusion row asserts record handles and links in a principle stay behind. +A principle statement's citation check reads links as inline `[label](target)` only: a bare URL, an autolink `<https://...>` and a reference-style `[label][ref]` travel into the bundle raw and the lint is silent on all three, while the manifest's exclusion row asserts record handles and links in a principle stay behind. ## Grounds