diff --git a/ingestion/internal/api/update.go b/ingestion/internal/api/update.go index c8d015c..69f4d25 100644 --- a/ingestion/internal/api/update.go +++ b/ingestion/internal/api/update.go @@ -107,17 +107,6 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash, // unvalidated (hyperguild#111: the same rule that guards note birth in // writeHallNote must guard a change to the same fields). contentFM, contentBody := parseFrontmatter(opts.Content) - if len(contentFM.lines) > 0 { - envelope := make(map[string]string, len(contentFM.lines)) - for _, l := range contentFM.lines { - if l.key != "" { - envelope[l.key] = l.value - } - } - if err := claim.ValidateEnvelope(envelope); err != nil { - return "", "", "", fmt.Errorf("claim envelope: %w", err) - } - } fm, _ := parseFrontmatter(string(prior)) for _, l := range contentFM.lines { @@ -131,6 +120,25 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash, fm.set("supersede_reason", opts.Reason) } + // Validate the envelope AFTER the merge, against the full field set that + // is about to reach disk — not just the caller's fragment. Validating the + // fragment alone would miss a caller that supersedes claim_provenance to + // empty while half_life/applies survive untouched from the prior note: + // each side looks individually fine, but the merged result is an + // unprovenanced envelope, which is the exact laundering this check + // exists to catch. (Caught in review before merge, not filed after — + // worth being honest about in the history: this second pass was the + // actual fix; the first one validated the wrong document.) + envelope := make(map[string]string, len(fm.lines)) + for _, l := range fm.lines { + if l.key != "" { + envelope[l.key] = l.value + } + } + if err := claim.ValidateEnvelope(envelope); err != nil { + return "", "", "", fmt.Errorf("claim envelope: %w", err) + } + out := []byte(fm.render() + contentBody) if err := os.WriteFile(abs, out, 0o644); err != nil { return "", "", "", fmt.Errorf("write: %w", err) diff --git a/ingestion/internal/api/update_test.go b/ingestion/internal/api/update_test.go index f47dd4b..85707f7 100644 --- a/ingestion/internal/api/update_test.go +++ b/ingestion/internal/api/update_test.go @@ -112,6 +112,29 @@ func TestUpdateNoteAcceptsValidEnvelopeAndMergesIntoFrontmatter(t *testing.T) { "exactly one open+close fence pair — the caller's own fence must not leak in as a second, inert block") } +func TestUpdateNoteRejectsClearingProvenanceWhileEnvelopeSurvives(t *testing.T) { + // Found in review, not written speculatively: validating only the + // caller's fragment (rather than the merged result) let a caller blank + // out claim_provenance alone while half_life/applies survived from the + // prior note untouched — each side looks fine on its own, the merged + // envelope on disk is the unprovenanced one the whole check exists to + // catch. + brainDir := t.TempDir() + original := "---\nwing: jepa-fx\nhall: facts\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\nold\n" + rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original) + + _, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{ + Path: rel, + Content: "---\nclaim_provenance: \n---\nnew body\n", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "claim envelope") + + got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel))) + require.NoError(t, err) + assert.Equal(t, original, string(got), "rejected update must leave the note byte-identical") +} + func TestUpdateNoteWithoutEnvelopeFieldsIsUnchangedFromToday(t *testing.T) { // Regression pin: plain fence-less content (the common case) must behave // exactly as it did before the envelope-merge fix.