The wrong assumption
frontmatter.IsNull assumes YAML spells null only in lowercase.
// internal/core/frontmatter/frontmatter.go:66-69
// IsNull treats an empty value and the YAML nulls ""/"null"/"~" as null.
func IsNull(v string) bool {
return v == "" || v == "null" || v == "~"
}
YAML 1.1 (!!null) and the YAML 1.2 core schema (§10.2.1.1) both resolve null | Null | NULL | ~ plus the empty scalar. The doc comment invokes "the YAML nulls" by name and then enumerates three of the four — a mis-statement, not a documented narrowing.
The codebase already holds the correct set, twice, in its own YAML scalar parser:
// internal/core/memory/yaml.go:526 (parse) and :949 (dump-quoting)
case "null", "~", "Null", "NULL":
Trigger and observed vs correct behaviour
internal/core/lifeboat/graveyard_abandoned.go:111-113 — note that the same predicate case-folds status but not the null literal:
status := strings.ToLower(gvUnquote(fields["status"].Value))
supBy := gvUnquote(fields["superseded_by"].Value)
if status != "superseded" && frontmatter.IsNull(supBy) {
return
}
Input: a repo passed to abcd disembark pack <target> <dest> containing an ADR with status: accepted and superseded_by: NULL.
Observed: a superseded-adr finding is written into graveyard/abandoned.json, with the null literal quoted as its supporting evidence:
{ "id": "adr-11", "signal": "superseded-adr", "summary": "ADR superseded",
"evidence": ["superseded_by: NULL", "docs/adr/0011-foreign.md"] }
Correct: no finding — NULL is a YAML null, i.e. not superseded.
Full matrix, reproduced end-to-end through the shipped verb against scratch target repos (all ADRs status: accepted):
superseded_by: |
reported superseded |
correct? |
null |
no |
✅ |
~ |
no |
✅ |
| (empty) |
no |
✅ |
NULL |
yes |
❌ |
Null |
yes |
❌ |
"NULL" |
yes |
❌ (gvUnquote strips the quotes first) |
adr-9 |
yes |
✅ |
Scope
In scope under the documented threat model. disembark pack takes an arbitrary target directory (internal/surface/cli/cli.go:483-490), and gvADRHomes deliberately scans the conventional foreign homes — convADRDirs = {adr, architecture/decisions, docs/adr, docs/adrs, docs/decisions} (internal/core/lifeboat/sources_conventions.go:148-150) — i.e. repos abcd never authored and whose spelling conventions it does not control. The trusted-worktree exemption covers the local worktree only; .abcd/development/research/notes/2026-07-17-bug-hunting-principles.md:109-118 scopes it explicitly, and item 3 of that same note names this parser as an unaudited surface processing "attacker markdown from embarked/packed lifeboats".
The one guard that catches this — record-lint's record_schema blocker (superseded_by 'NULL' is not a record handle) — provably cannot run on this path: internal/core/lifeboat imports no lint package, and record-lint pointed at a foreign repo exits 2 (load config: open .abcd/record-lint.json: no such file or directory) and never inspects docs/adr/ at all.
The false finding propagates: graveyard/abandoned.json is read back by internal/core/lifeboat/graveyard_lessons.go:63 and synthesis_principles.go:486, and agents/graveyard-interpreter.md:25,82 instructs the host agent to cite these finding ids when synthesising layer-3 lessons — so a fabricated "this decision was abandoned" claim widens the citable id set an LLM writes project history from.
CWE anchor: CWE-178 (Improper Handling of Case Sensitivity). Matches the playbook row "a case-sensitive regex/compare on a value compared case-insensitively in the real world".
Sibling sweep
Grepping the pattern rather than the instance — exactly two null predicates exist in the tree, both narrow, plus a third narrow variant:
| Site |
Status |
internal/core/frontmatter/frontmatter.go:68 |
the canonical one — narrow |
internal/core/lint/lint.go:2398 |
a second private copy, byte-identical and equally narrow |
internal/core/lint/schema.go:440 (isAbsentValue) |
third narrow variant |
internal/core/memory/yaml.go:526,949 |
the wide, correct set |
Every frontmatter.IsNull call site, checked individually against a NULL value:
| Call site |
Effect |
Silent? |
lifeboat/graveyard_abandoned.go:113,122 |
false superseded-adr finding in a machine-read artefact |
silent — the only one |
spec/store.go:177 |
falls through to specNumRe → hard error, aborting the spec-id mint |
loud, fail-closed |
intent/lifecycle.go:129 |
draft refused: spec_id "NULL" already set (half-planned) |
loud |
intent/lifecycle.go:154 |
kind: NULL survives as the literal kind and is written back to frontmatter |
loud downstream |
intent/lifecycle.go:404, intent/ready.go:122,152 |
bogus LinkedPair / detail string / spec link |
cosmetic |
record/record.go:166,172,294 |
describe emits Links[...] = "NULL" |
cosmetic |
capture/validate.go:104 |
ParseImpact fails → ErrMalformedFrontmatter |
loud |
The abcd-native sites all fail loudly and consistently because abcd itself writes lowercase null (internal/core/intent/create.go:134-135) and record-lint gates its own corpus. Lifeboat is the site where neither of those holds.
Coverage gap: TestIsNull (internal/core/frontmatter/frontmatter_test.go:61-72) asserts ""/"null"/"~" true and "itd-9"/"spc-1"/"standalone" false — no case variant in either direction. capture/parse_test.go:123-129 mirrors the same three.
Fix direction
One sentence, as mandated: widen the canonical null set to the four YAML spellings — and widen lint.isNull (lint/lint.go:2398) in the same change, or the two-gate agreement that capture/validate.go:93-98 explicitly depends on will break.
Validator confirmations
Both lenses were run independently and instructed to refute; both failed to.
-
Reachability lens — "I reproduced it end-to-end through the shipped abcd disembark pack against a foreign target repo — superseded_by: NULL and superseded_by: Null on status: accepted ADRs both emit "summary": "ADR superseded" findings into graveyard/abandoned.json (with lowercase null/~ correctly excluded as controls) — and the repo's own written threat model places the pack path over an arbitrary target repo inside scope while its only guard, record-lint, provably cannot run there and never inspects conventional docs/adr/ at all."
-
Correctness lens — "IsNull's own comment claims to implement 'the YAML nulls' but omits Null/NULL, which both YAML 1.1 and 1.2's core schema define as nulls and which this repo's own YAML code already handles at memory/yaml.go:526 and :949; a live ADR carrying superseded_by: NULL is empirically emitted into graveyard/abandoned.json as a superseded ADR, silently, with superseded_by: NULL as its evidence."
Both independently rated this minor: a silent correctness inversion on an evidence-only artefact, no security, data-loss, or availability impact, and recoverable by a human comparing the finding against the ADR's status:.
The wrong assumption
frontmatter.IsNullassumes YAML spells null only in lowercase.YAML 1.1 (
!!null) and the YAML 1.2 core schema (§10.2.1.1) both resolvenull | Null | NULL | ~plus the empty scalar. The doc comment invokes "the YAML nulls" by name and then enumerates three of the four — a mis-statement, not a documented narrowing.The codebase already holds the correct set, twice, in its own YAML scalar parser:
Trigger and observed vs correct behaviour
internal/core/lifeboat/graveyard_abandoned.go:111-113— note that the same predicate case-foldsstatusbut not the null literal:Input: a repo passed to
abcd disembark pack <target> <dest>containing an ADR withstatus: acceptedandsuperseded_by: NULL.Observed: a
superseded-adrfinding is written intograveyard/abandoned.json, with the null literal quoted as its supporting evidence:{ "id": "adr-11", "signal": "superseded-adr", "summary": "ADR superseded", "evidence": ["superseded_by: NULL", "docs/adr/0011-foreign.md"] }Correct: no finding —
NULLis a YAML null, i.e. not superseded.Full matrix, reproduced end-to-end through the shipped verb against scratch target repos (all ADRs
status: accepted):superseded_by:null~NULLNull"NULL"gvUnquotestrips the quotes first)adr-9Scope
In scope under the documented threat model.
disembark packtakes an arbitrary target directory (internal/surface/cli/cli.go:483-490), andgvADRHomesdeliberately scans the conventional foreign homes —convADRDirs = {adr, architecture/decisions, docs/adr, docs/adrs, docs/decisions}(internal/core/lifeboat/sources_conventions.go:148-150) — i.e. repos abcd never authored and whose spelling conventions it does not control. The trusted-worktree exemption covers the local worktree only;.abcd/development/research/notes/2026-07-17-bug-hunting-principles.md:109-118scopes it explicitly, and item 3 of that same note names this parser as an unaudited surface processing "attacker markdown from embarked/packed lifeboats".The one guard that catches this — record-lint's
record_schemablocker (superseded_by 'NULL' is not a record handle) — provably cannot run on this path:internal/core/lifeboatimports no lint package, and record-lint pointed at a foreign repo exits 2 (load config: open .abcd/record-lint.json: no such file or directory) and never inspectsdocs/adr/at all.The false finding propagates:
graveyard/abandoned.jsonis read back byinternal/core/lifeboat/graveyard_lessons.go:63andsynthesis_principles.go:486, andagents/graveyard-interpreter.md:25,82instructs the host agent to cite these finding ids when synthesising layer-3 lessons — so a fabricated "this decision was abandoned" claim widens the citable id set an LLM writes project history from.CWE anchor: CWE-178 (Improper Handling of Case Sensitivity). Matches the playbook row "a case-sensitive regex/compare on a value compared case-insensitively in the real world".
Sibling sweep
Grepping the pattern rather than the instance — exactly two null predicates exist in the tree, both narrow, plus a third narrow variant:
internal/core/frontmatter/frontmatter.go:68internal/core/lint/lint.go:2398internal/core/lint/schema.go:440(isAbsentValue)internal/core/memory/yaml.go:526,949Every
frontmatter.IsNullcall site, checked individually against aNULLvalue:lifeboat/graveyard_abandoned.go:113,122superseded-adrfinding in a machine-read artefactspec/store.go:177specNumRe→ hard error, aborting the spec-id mintintent/lifecycle.go:129spec_id "NULL" already set (half-planned)intent/lifecycle.go:154kind: NULLsurvives as the literal kind and is written back to frontmatterintent/lifecycle.go:404,intent/ready.go:122,152LinkedPair/ detail string / spec linkrecord/record.go:166,172,294describeemitsLinks[...] = "NULL"capture/validate.go:104ParseImpactfails →ErrMalformedFrontmatterThe abcd-native sites all fail loudly and consistently because abcd itself writes lowercase
null(internal/core/intent/create.go:134-135) and record-lint gates its own corpus. Lifeboat is the site where neither of those holds.Coverage gap:
TestIsNull(internal/core/frontmatter/frontmatter_test.go:61-72) asserts""/"null"/"~"true and"itd-9"/"spc-1"/"standalone"false — no case variant in either direction.capture/parse_test.go:123-129mirrors the same three.Fix direction
One sentence, as mandated: widen the canonical null set to the four YAML spellings — and widen
lint.isNull(lint/lint.go:2398) in the same change, or the two-gate agreement thatcapture/validate.go:93-98explicitly depends on will break.Validator confirmations
Both lenses were run independently and instructed to refute; both failed to.
Reachability lens — "I reproduced it end-to-end through the shipped
abcd disembark packagainst a foreign target repo —superseded_by: NULLandsuperseded_by: Nullonstatus: acceptedADRs both emit"summary": "ADR superseded"findings intograveyard/abandoned.json(with lowercasenull/~correctly excluded as controls) — and the repo's own written threat model places the pack path over an arbitrary target repo inside scope while its only guard, record-lint, provably cannot run there and never inspects conventionaldocs/adr/at all."Correctness lens — "
IsNull's own comment claims to implement 'the YAML nulls' but omitsNull/NULL, which both YAML 1.1 and 1.2's core schema define as nulls and which this repo's own YAML code already handles atmemory/yaml.go:526and:949; a live ADR carryingsuperseded_by: NULLis empirically emitted intograveyard/abandoned.jsonas a superseded ADR, silently, withsuperseded_by: NULLas its evidence."Both independently rated this minor: a silent correctness inversion on an evidence-only artefact, no security, data-loss, or availability impact, and recoverable by a human comparing the finding against the ADR's
status:.