From ffceb635abb12c53f4fa37712fe063de24f8bb07 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 22 Aug 2026 09:39:04 +0200 Subject: [PATCH] feat(brain): validate the claim-property envelope on write (brain#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:), 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. --- ingestion/internal/api/handler.go | 37 ++++++++-- ingestion/internal/api/handler_test.go | 57 +++++++++++++++ ingestion/internal/claim/claim.go | 98 ++++++++++++++++++++++++++ ingestion/internal/claim/claim_test.go | 89 +++++++++++++++++++++++ 4 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 ingestion/internal/claim/claim.go create mode 100644 ingestion/internal/claim/claim_test.go diff --git a/ingestion/internal/api/handler.go b/ingestion/internal/api/handler.go index 48a65b3..d6cbb2a 100644 --- a/ingestion/internal/api/handler.go +++ b/ingestion/internal/api/handler.go @@ -12,6 +12,7 @@ import ( "time" "github.com/mathiasbq/hyperguild/ingestion/internal/brain" + "github.com/mathiasbq/hyperguild/ingestion/internal/claim" "github.com/mathiasbq/hyperguild/ingestion/internal/extract" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" "github.com/mathiasbq/hyperguild/ingestion/internal/search" @@ -152,15 +153,24 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) { if err != nil { return "", err } - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return "", fmt.Errorf("create hall dir: %w", err) - } - existingFields, body := splitFrontmatter(opts.Content) existingByKey := make(map[string]frontmatterField, len(existingFields)) for _, f := range existingFields { existingByKey[f.key] = f } + + // Validate the claim-property envelope BEFORE creating the directory, so a + // rejected write leaves nothing behind — not even an empty hall dir + // (brain#21). Fail closed: a value outside the enum 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. + if err := claim.ValidateEnvelope(scalarValues(existingFields)); err != nil { + return "", fmt.Errorf("claim envelope: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return "", fmt.Errorf("create hall dir: %w", err) + } emitted := make(map[string]bool, 6) var fm strings.Builder @@ -218,6 +228,25 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) { return filepath.ToSlash(rel), nil } +// scalarValues flattens frontmatter fields to key -> scalar value for +// validation. Multi-line values (lists, nested blocks) yield the text after +// the colon on the first line, which is empty for a list — correct here, +// since every claim-property field is a scalar enum. +func scalarValues(fields []frontmatterField) map[string]string { + out := make(map[string]string, len(fields)) + for _, f := range fields { + if len(f.lines) == 0 { + continue + } + _, value, found := strings.Cut(f.lines[0], ":") + if !found { + continue + } + out[f.key] = strings.TrimSpace(value) + } + return out +} + // frontmatterField is one top-level YAML key from a frontmatter block, // along with its raw line and any indented continuation lines (e.g. a // bulleted list value spanning multiple lines). diff --git a/ingestion/internal/api/handler_test.go b/ingestion/internal/api/handler_test.go index fa12c48..2c4e6dc 100644 --- a/ingestion/internal/api/handler_test.go +++ b/ingestion/internal/api/handler_test.go @@ -440,3 +440,60 @@ func TestIngestPath_Directory(t *testing.T) { require.True(t, ok, "pages must be an array") assert.NotEmpty(t, pagesSlice) } + +// --- brain#21: claim-property envelope on the write path ------------------- + +func TestWriteNote_PreservesClaimEnvelopeVerbatim(t *testing.T) { + // writeHallNote already passes unknown keys through; this pins that + // behaviour for the envelope specifically, so a future refactor of the + // merge loop cannot silently drop it. + dir := t.TempDir() + + rel, err := api.WriteNote(dir, api.WriteNoteOptions{ + Content: "---\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nA durable claim.\n", + Wing: "homelab", + Hall: "decisions", + }) + require.NoError(t, err) + + got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) + require.NoError(t, err) + assert.Contains(t, string(got), "half_life: durable") + assert.Contains(t, string(got), "applies: universal") + assert.Contains(t, string(got), "claim_provenance: asserted") +} + +func TestWriteNote_RejectsInvalidClaimEnvelopeAndWritesNothing(t *testing.T) { + // Fail closed: a value outside the enum must not land. A lint skips what + // it cannot parse, so an unvalidated write produces a note that reads as + // classified while nothing acts on it. + dir := t.TempDir() + + _, err := api.WriteNote(dir, api.WriteNoteOptions{ + Content: "---\nhalf_life: forever\nclaim_provenance: asserted\n---\n\nBody.\n", + Filename: "bad-envelope", + Wing: "homelab", + Hall: "facts", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "half_life") + + _, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/bad-envelope.md")) + assert.True(t, os.IsNotExist(statErr), "a rejected write must leave no file behind") +} + +func TestWriteNote_RejectsClaimFieldWithoutProvenance(t *testing.T) { + dir := t.TempDir() + + _, err := api.WriteNote(dir, api.WriteNoteOptions{ + Content: "---\napplies: homelab\n---\n\nBody.\n", + Filename: "no-provenance", + Wing: "homelab", + Hall: "facts", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "claim_provenance") + + _, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/no-provenance.md")) + assert.True(t, os.IsNotExist(statErr)) +} diff --git a/ingestion/internal/claim/claim.go b/ingestion/internal/claim/claim.go new file mode 100644 index 0000000..bbbf1e8 --- /dev/null +++ b/ingestion/internal/claim/claim.go @@ -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:`) — 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, ", ")) +} diff --git a/ingestion/internal/claim/claim_test.go b/ingestion/internal/claim/claim_test.go new file mode 100644 index 0000000..901d430 --- /dev/null +++ b/ingestion/internal/claim/claim_test.go @@ -0,0 +1,89 @@ +// Package claim_test covers the claim-property envelope (brain#21). +package claim_test + +import ( + "testing" + + "github.com/mathiasbq/hyperguild/ingestion/internal/claim" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateEnvelope(t *testing.T) { + tests := []struct { + name string + fm map[string]string + wantErr string + }{ + { + name: "absent is fine — the fields are opt-in on new writes", + fm: map[string]string{"wing": "homelab", "hall": "facts"}, + }, + { + name: "complete envelope with provenance", + fm: map[string]string{ + "half_life": "durable", "applies": "universal", "claim_provenance": "asserted", + }, + }, + { + name: "inferred provenance is equally valid — it just must be stated", + fm: map[string]string{ + "half_life": "volatile", "applies": "local", "claim_provenance": "inferred", + }, + }, + { + // A value nobody can act on is worse than no value: a lint + // silently skips it, so the note looks classified and isn't. + name: "unknown half_life value", + fm: map[string]string{"half_life": "forever", "claim_provenance": "asserted"}, + wantErr: `half_life: "forever" is not one of volatile, seasonal, durable`, + }, + { + name: "unknown applies value", + fm: map[string]string{"applies": "galaxy", "claim_provenance": "asserted"}, + wantErr: `applies: "galaxy" is not one of local, homelab, universal`, + }, + { + // brain#21: a value a model inferred must never be + // indistinguishable from one Mathias asserted. Writing the field + // without saying who set it is exactly that laundering. + name: "envelope field without provenance", + fm: map[string]string{"half_life": "durable"}, + wantErr: "claim_provenance is required when half_life or applies is set", + }, + { + name: "applies without provenance", + fm: map[string]string{"applies": "homelab"}, + wantErr: "claim_provenance is required when half_life or applies is set", + }, + { + name: "unknown provenance value", + fm: map[string]string{"half_life": "durable", "claim_provenance": "probably"}, + wantErr: `claim_provenance: "probably" is not one of asserted, inferred`, + }, + { + // Provenance alone says who set fields that aren't there. + name: "provenance without any envelope field", + fm: map[string]string{"claim_provenance": "asserted"}, + wantErr: "claim_provenance is set but neither half_life nor applies is", + }, + { + // The glossary wing has used scope: homelab-core since #5. The + // envelope deliberately does NOT claim that key (brain#21). + name: "glossary scope is untouched", + fm: map[string]string{"scope": "homelab-core", "term": "koala"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := claim.ValidateEnvelope(tt.fm) + if tt.wantErr == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +}