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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .abcd/development/brief/04-surfaces/02-disembark.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ a section of its own.
├── _provenance.json # the lifeboat marker and re-pack gate key, written last
├── coverage.json # per-section status (grounded|partial|blank), evidence, what was searched
├── coverage.md # rendered
├── brief/ # the brief, section by section, grounded from the source
├── brief/ # the brief, section by section, grounded from the source; a partial one says why
│ ├── 01-product/ … 06-delivery/
│ └── glossary/
├── graveyard/ # what the project tried and abandoned
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
schema_version: 1
id: "iss-2610072347247487"
slug: "the-disembark-probe-s-invariants-naming"
severity: "minor"
category: "drift"
source: "agent-finding"
found_during: "the disembark probe lane (run-2610072231362768), 2026-10-07"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/lifeboat"
remedy: "Have the invariants, naming and personas sources read their own brief file as the open-questions and tradeoffs sources now do (an authored file grounds the section, a stub makes it partial), and print each partial section's reason in the pack's per-section file as the coverage report does."
resolution: "The invariants, naming and personas native adapters now read the section's own brief file through nativeWithBriefFile, as open-questions and tradeoffs do: an authored file grounds the section, a stub is cited and lifts a blank to partial with a reason. The pack's per-section brief file prints a partial section's reason as 'Why partial:', cleaned like every other untrusted field."
impact: fix
resolved_by:
commit: "aa51824ccdcbc36b188ae2e7f4dc27c5b8af7109"
---

The disembark probe's invariants, naming and personas sources ignore their own brief files, unlike open-questions and tradeoffs since the probe lane, and the pack's per-section file does not print a partial section's reason, which coverage.json and the text render now carry.

## Grounds

- pursued: a repository whose only signal for invariants, naming or personas is an authored brief file probes grounded for that section (a stub probes partial with a reason), and every partial section's packed brief file states its reason; TestNativeConstraintAndPersonaSectionsGroundFromTheirBriefFiles, TestPackSectionFileStatesWhyPartial and TestBriefSectionDocCleansItsPartialReason would fail if an adapter ignored its brief file or a packed partial omitted its reason
11 changes: 11 additions & 0 deletions internal/core/lifeboat/mdrender_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,14 @@ func TestBlockValueKeepsATripleBacktickSpanAProseLine(t *testing.T) {
}
}
}

func TestBriefSectionDocCleansItsPartialReason(t *testing.T) {
md := string(briefSectionDoc(SectionCoverage{
Name: "product/context", Status: StatusPartial,
Reason: "Found docs/<script>x.md and [a](http://example.com) <!--c",
}))
assertNoLiveHazard(t, "brief section doc reason", md)
if !strings.Contains(md, "Why partial: ") {
t.Errorf("partial brief section doc states no reason:\n%s", md)
}
}
38 changes: 38 additions & 0 deletions internal/core/lifeboat/partial_reason_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,41 @@ func TestPartialIsBuiltOnlyThroughTheConstructor(t *testing.T) {
}
}
}

// TestPackSectionFileStatesWhyPartial carries the reason contract into the
// pack: each partial section's brief file in the lifeboat says why it is
// partial, as coverage.json and the rendered report do, and a grounded one
// says nothing of the kind (iss-2610072347247487).
func TestPackSectionFileStatesWhyPartial(t *testing.T) {
sawPartial := false
for name, dir := range partialReasonFixtures(t) {
cov, err := Probe(dir)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
lb, err := Plan(dir)
if err != nil {
t.Fatalf("%s: plan: %v", name, err)
}
for _, s := range cov.Sections {
leaf, ok := briefLeaf(s.Name)
if !ok || s.Status == StatusBlank {
continue
}
md := string(planFile(t, lb, "brief/"+leaf).Content)
if s.Status != StatusPartial {
if strings.Contains(md, "Why partial:") {
t.Errorf("%s: grounded %s's brief file states a partial reason:\n%s", name, s.Name, md)
}
continue
}
sawPartial = true
if want := "Why partial: " + mdInline(s.Reason); !strings.Contains(md, want) {
t.Errorf("%s: partial %s's brief file does not state its reason %q:\n%s", name, s.Name, s.Reason, md)
}
}
}
if !sawPartial {
t.Fatal("no fixture packed a partial section; the contract was not exercised")
}
}
12 changes: 10 additions & 2 deletions internal/core/lifeboat/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,9 @@ func lifeboatPathFor(section Section) string {
}

// briefSectionDoc renders one grounded/partial brief section as a citation map:
// its status, the tier and confidence, and the evidence it was grounded from.
// It is deterministic and cites every source — never synthesised prose.
// its status, the tier and confidence, why a partial is partial (the reason the
// coverage report carries), and the evidence it was grounded from. It is
// deterministic and cites every source — never synthesised prose.
func briefSectionDoc(s SectionCoverage) []byte {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", s.Name)
Expand All @@ -488,6 +489,13 @@ func briefSectionDoc(s SectionCoverage) []byte {
fmt.Fprintf(&b, " (%s, %s confidence)", s.Tier, s.Confidence)
}
b.WriteString("\n\n")
// The reason names repository paths, which a hostile repository controls, so
// it is cleaned like every other untrusted field the lifeboat writes.
if s.Status == StatusPartial {
if r := mdInline(s.Reason); r != "" {
fmt.Fprintf(&b, "Why partial: %s\n\n", r)
}
}
if len(s.Evidence) > 0 {
b.WriteString("Grounded from:\n\n")
ev := append([]string(nil), s.Evidence...)
Expand Down
64 changes: 38 additions & 26 deletions internal/core/lifeboat/sources_native.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,6 @@ const (
nativeIntentsDir = ".abcd/development/intents"
nativeGlossaryDir = ".abcd/development/brief/glossary"
nativeDecisions = ".abcd/work/DECISIONS.md"
nativePersonas = ".abcd/development/brief/01-product/05-personas.md"
)

// nativeIssueStates are the capture-ledger subdirectories, in a fixed order so
Expand Down Expand Up @@ -424,15 +423,23 @@ func (nativeSpineSource) Probe(ctx *SourceContext) Evidence {
}
}

// nativeInvariantsSource grounds "constraints/invariants" from the conventions
// router (AGENTS.md / CLAUDE.md) and any lint configuration under .abcd/. The
// router grounds it; lint config alone is partial. Blank when neither exists.
// nativeInvariantsSource grounds "constraints/invariants" from the section's own
// brief file, the conventions router (AGENTS.md / CLAUDE.md) and any lint
// configuration under .abcd/. An authored brief file or the router grounds it;
// lint config alone, or a stub brief file, is partial. Blank when the record has
// none of them.
type nativeInvariantsSource struct{}

func (nativeInvariantsSource) Section() Section { return "constraints/invariants" }
func (nativeInvariantsSource) Tier() Tier { return TierNative }

func (nativeInvariantsSource) Probe(ctx *SourceContext) Evidence {
func (s nativeInvariantsSource) Probe(ctx *SourceContext) Evidence {
return nativeWithBriefFile(ctx, s.Section(), s.probeRecord(ctx))
}

// probeRecord is the invariants reading from the conventions router and lint
// configuration alone, before the brief file is consulted.
func (nativeInvariantsSource) probeRecord(ctx *SourceContext) Evidence {
var sources []string
hasRouter := false
for _, f := range []string{"AGENTS.md", "CLAUDE.md"} {
Expand All @@ -455,20 +462,27 @@ func (nativeInvariantsSource) Probe(ctx *SourceContext) Evidence {
sort.Strings(sources)
if !hasRouter {
return partial(ConfidenceMedium, sources,
"Found lint configuration under .abcd/ but no conventions router, so some rules are enforced but none is stated; an AGENTS.md or CLAUDE.md stating the invariants would ground it.")
"Found lint configuration under .abcd/ but no conventions router, so some rules are enforced but none is stated; an AGENTS.md or CLAUDE.md stating the invariants, or an authored "+nativeSectionBriefFile("constraints/invariants")+", would ground it.")
}
return Evidence{Status: StatusGrounded, Confidence: ConfidenceHigh, Sources: sources}
}

// nativeNamingSource grounds "constraints/naming" from the brief glossary. A
// glossary carrying real prose grounds it; an empty or stub glossary is partial;
// no glossary directory is a blank.
// nativeNamingSource grounds "constraints/naming" from the section's own brief
// file and the brief glossary. An authored brief file or a glossary carrying
// real prose grounds it; a stub glossary or a stub brief file is partial. Blank
// when the record has neither.
type nativeNamingSource struct{}

func (nativeNamingSource) Section() Section { return "constraints/naming" }
func (nativeNamingSource) Tier() Tier { return TierNative }

func (nativeNamingSource) Probe(ctx *SourceContext) Evidence {
func (s nativeNamingSource) Probe(ctx *SourceContext) Evidence {
return nativeWithBriefFile(ctx, s.Section(), s.probeRecord(ctx))
}

// probeRecord is the naming reading from the brief glossary alone, before the
// section's brief file is consulted.
func (nativeNamingSource) probeRecord(ctx *SourceContext) Evidence {
if !ctx.IsDir(nativeGlossaryDir) {
return blank(
[]string{nativeGlossaryDir},
Expand All @@ -495,29 +509,27 @@ func (nativeNamingSource) Probe(ctx *SourceContext) Evidence {
}
if body < nativeGroundedBodyBytes {
return partial(ConfidenceMedium, dedupeSorted(files),
fmt.Sprintf("Found %d glossary file(s) under %s carrying %d characters of body prose, under the %d an authored glossary carries; defining the project's terms there in prose would ground it.",
len(files), nativeGlossaryDir, body, nativeGroundedBodyBytes))
fmt.Sprintf("Found %d glossary file(s) under %s carrying %d characters of body prose, under the %d an authored glossary carries; defining the project's terms there in prose, or an authored %s, would ground it.",
len(files), nativeGlossaryDir, body, nativeGroundedBodyBytes, nativeSectionBriefFile("constraints/naming")))
}
return Evidence{Status: StatusGrounded, Confidence: ConfidenceHigh, Sources: dedupeSorted(files)}
}

// nativePersonasSource is the deliberately hard case: "product/personas" is a
// human question rarely written down anywhere. It reaches at most PARTIAL, and
// only when an authored personas file exists; otherwise it returns a blank —
// the expected, correct result for a section a repository cannot supply.
// nativePersonasSource grounds "product/personas" from the section's own brief
// file, as every other brief section is grounded: an authored file grounds it, a
// stub one is partial, and a missing one is a blank. The section is human-owned
// (adr-36) — who a product serves is not derivable from a repository — so the
// record holds no other signal for it, and the blank, the expected result where
// no one has written the personas down, carries the question only a person can
// answer.
type nativePersonasSource struct{}

func (nativePersonasSource) Section() Section { return "product/personas" }
func (nativePersonasSource) Tier() Tier { return TierNative }

func (nativePersonasSource) Probe(ctx *SourceContext) Evidence {
data, ok := ctx.ReadFile(nativePersonas)
if !ok || nativeBodyBytes(data) < nativeGroundedBodyBytes {
return blank(
[]string{nativePersonas},
"Who are the personas this product serves? Personas are a human question, rarely derivable from a repository.",
)
}
return partial(ConfidenceLow, []string{nativePersonas},
"Found an authored "+nativePersonas+"; personas stay partial by design, because who a product serves is confirmed by a person, not derived from a repository — a person's confirmation would ground it.")
func (s nativePersonasSource) Probe(ctx *SourceContext) Evidence {
return nativeWithBriefFile(ctx, s.Section(), blank(
nil,
"Who are the personas this product serves? Personas are a human question, rarely derivable from a repository.",
))
}
62 changes: 59 additions & 3 deletions internal/core/lifeboat/sources_native_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,9 @@ func TestNativeInvariantsGround(t *testing.T) {
}
}

// TestNativePersonasBlank holds the deliberately-hard case: with no personas file
// in the fixture, "product/personas" returns a blank naming what it searched and
// the question a human must answer — never grounded.
// TestNativePersonasBlank holds the human-owned case: with no personas file in
// the fixture, "product/personas" returns a blank naming what it searched and the
// question a human must answer.
func TestNativePersonasBlank(t *testing.T) {
ctx, err := newSourceContext(nativeTierFixture(t))
if err != nil {
Expand Down Expand Up @@ -316,3 +316,59 @@ func TestNativeEvidenceSectionsGroundFromTheirBriefFiles(t *testing.T) {
t.Errorf("stub open-questions = %s (reason %q), want partial with a reason", ev.Status, ev.Reason)
}
}

// TestNativeConstraintAndPersonaSectionsGroundFromTheirBriefFiles: the
// invariants, naming and personas adapters read the section's own brief file,
// as the evidence adapters do — an authored file grounds the section even when
// the record holds no conventions router, glossary or other signal, and a stub
// one alone is partial and says why (iss-2610072347247487).
func TestNativeConstraintAndPersonaSectionsGroundFromTheirBriefFiles(t *testing.T) {
prose := "This section is authored by the project, not scaffolded. It states what " +
"the team holds fixed and who the work is for, each with the reason it was " +
"settled that way, so a reader can pick the work up without reconstructing " +
"it from history or asking the people who wrote it.\n"
files := map[Section]string{
"constraints/invariants": ".abcd/development/brief/02-constraints/03-invariants.md",
"constraints/naming": ".abcd/development/brief/02-constraints/04-naming.md",
"product/personas": ".abcd/development/brief/01-product/05-personas.md",
}

authored := map[string]string{}
stubbed := map[string]string{}
for _, file := range files {
authored[file] = "# Section\n\n" + prose
stubbed[file] = "# Section\n\nTODO.\n"
}
dir := t.TempDir()
writeTree(t, dir, authored)
ctx, err := newSourceContext(dir)
if err != nil {
t.Fatal(err)
}
defer ctx.Close()
stub := t.TempDir()
writeTree(t, stub, stubbed)
sctx, err := newSourceContext(stub)
if err != nil {
t.Fatal(err)
}
defer sctx.Close()

for section, file := range files {
ev := nativeSourceForSection(t, section).Probe(ctx)
if ev.Status != StatusGrounded {
t.Errorf("%s = %s with an authored %s, want grounded", section, ev.Status, file)
}
if !containsSource(ev.Sources, file) {
t.Errorf("%s evidence = %v, want %s cited", section, ev.Sources, file)
}

ev = nativeSourceForSection(t, section).Probe(sctx)
if ev.Status != StatusPartial || ev.Reason == "" {
t.Errorf("stub %s = %s (reason %q), want partial with a reason", section, ev.Status, ev.Reason)
}
if !containsSource(ev.Sources, file+" (stub)") {
t.Errorf("stub %s evidence = %v, want %s cited as a stub", section, ev.Sources, file)
}
}
}
Loading