Skip to content

frontmatter.IsNull misses the YAML nulls Null/NULL, so disembark pack reports a live ADR as superseded — quoting superseded_by: NULL as its evidence #290

Description

@REPPL

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:.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingneeds-fixvalidated bug awaiting a fixseverity:minornarrow or cosmetic defect

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions