feat(brain): validate the claim-property envelope on write (brain#21)
CI / Lint / Test / Vet (pull_request) Successful in 40s
CI / Mirror to GitHub (pull_request) Skipped

First half of brain#21: the applicability envelope — half_life (volatile|
seasonal|durable), applies (local|homelab|universal), and claim_provenance
(asserted|inferred). The strength half (confidence, contested) follows once
the ingest-time contradiction detector exists to populate it.

writeHallNote already passed unknown frontmatter keys through verbatim, so
preservation was free — a test now pins it so a refactor of the merge loop
cannot silently drop the fields. What was missing is validation, and it
fails closed:

- a value outside the enum is rejected. An unvalidated value is worse than
  an absent one, because a lint skips what it cannot parse and the note then
  reads as classified while nothing acts on it;
- an envelope field without claim_provenance is rejected. A value a model
  inferred must never be indistinguishable from one Mathias asserted —
  otherwise a single classifier pass launders machine guesses into apparent
  human judgement across 1113 notes, which is precisely how a weak claim
  hardens into accepted fact;
- provenance without any envelope field is rejected too: it says who set
  fields that are not there.

Validation runs before MkdirAll, so a rejected write leaves nothing behind —
not even an empty hall directory.

The field is `applies`, not `scope`, as brain#19 proposed: `scope:` has been
in use since brain#5 for glossary terms (homelab-core, project:<repo>), a
disjoint value space on the same key. The shipped schema keeps the name.

Notes with none of these fields are byte-identical to before. Bulk backfill
across the corpus stays out of scope — the same failure mode as the
extraction pass that produced 28 wrong wikilinks out of 67.

Schema documented in brain/CLAUDE.md (via brain_edit_root, which produced
the #97 audit record for the rewrite).

Refs brain#21, brain#19 (superseded), brain#5, hyperguild#97.
This commit is contained in:
2026-08-22 09:39:04 +02:00
parent b6ceccca26
commit ffceb635ab
4 changed files with 277 additions and 4 deletions
+98
View File
@@ -0,0 +1,98 @@
// Package claim validates the claim-property frontmatter that says when and
// where a note's claim holds, and who decided that (brain#21).
//
// Two families were designed together and land separately. This is the
// applicability envelope:
//
// half_life: volatile | seasonal | durable — how fast it decays
// applies: local | homelab | universal — how widely it holds
// claim_provenance: asserted | inferred — who set the above
//
// The strength family (confidence, contested) follows once the ingest-time
// contradiction detector exists to populate it.
//
// Why the envelope is validated rather than merely accepted: a value outside
// the enum is worse than an absent one. A lint skips what it cannot parse, so
// the note reads as classified while nothing acts on it — the field becomes
// decoration that suppresses the very review it was added to trigger.
//
// Why `applies` and not `scope`: `scope:` has been in use since brain#5 for
// glossary terms (`homelab-core`, `project:<repo>`) — a disjoint value space
// on the same key. The shipped schema keeps the name; this one takes another.
package claim
import (
"fmt"
"strings"
)
// Field names, exported so writers and lints agree on the spelling.
const (
FieldHalfLife = "half_life"
FieldApplies = "applies"
FieldProvenance = "claim_provenance"
)
var (
halfLives = []string{"volatile", "seasonal", "durable"}
appliesTo = []string{"local", "homelab", "universal"}
provenances = []string{"asserted", "inferred"}
)
// ValidateEnvelope checks the envelope fields of one note's frontmatter.
//
// Absent fields are valid: the envelope is opt-in for new writes, and a
// corpus-wide backfill is deliberately out of scope (brain#21) — inferring
// 1113 notes' properties in bulk has the same failure mode as the extraction
// pass that produced 28 wrong wikilinks out of 67.
func ValidateEnvelope(fm map[string]string) error {
halfLife, hasHalfLife := present(fm, FieldHalfLife)
applies, hasApplies := present(fm, FieldApplies)
prov, hasProv := present(fm, FieldProvenance)
if hasHalfLife {
if err := oneOf(FieldHalfLife, halfLife, halfLives); err != nil {
return err
}
}
if hasApplies {
if err := oneOf(FieldApplies, applies, appliesTo); err != nil {
return err
}
}
if hasProv {
if err := oneOf(FieldProvenance, prov, provenances); err != nil {
return err
}
}
// The field and its provenance travel together or not at all. A value
// whose author is unknown cannot be trusted differently from one Mathias
// asserted, which is how an inferred guess hardens into accepted fact.
switch {
case (hasHalfLife || hasApplies) && !hasProv:
return fmt.Errorf("%s is required when %s or %s is set",
FieldProvenance, FieldHalfLife, FieldApplies)
case hasProv && !hasHalfLife && !hasApplies:
return fmt.Errorf("%s is set but neither %s nor %s is",
FieldProvenance, FieldHalfLife, FieldApplies)
}
return nil
}
// present reports a trimmed field value and whether it was set to anything
// non-empty. A key written with an empty value is treated as absent rather
// than as an error: that is a caller emitting a placeholder, not asserting.
func present(fm map[string]string, key string) (string, bool) {
v := strings.TrimSpace(fm[key])
return v, v != ""
}
func oneOf(field, value string, allowed []string) error {
for _, a := range allowed {
if a == value {
return nil
}
}
return fmt.Errorf("%s: %q is not one of %s", field, value, strings.Join(allowed, ", "))
}