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.
99 lines
3.5 KiB
Go
99 lines
3.5 KiB
Go
// 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, ", "))
|
|
}
|