From e2cb315b31a50505779c28912440856d51b6c5fc Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 22 Sep 2026 23:24:50 +0200 Subject: [PATCH] fix(api): validate the merged envelope on supersede, not the caller's fragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught a real bypass in the #111 fix before merge: the previous commit validated only the frontmatter fence the caller supplied, not the merged result that actually reaches disk. A caller could blank claim_provenance alone (absent per claim.present's "empty is unset" rule) while half_life/applies survived untouched from the prior note — each side individually looked fine, the merged envelope on disk was the unprovenanced one the whole check exists to catch. That's the exact laundering #111 named as the worse of its two gaps. Fixed by computing the envelope from fm (post-merge, pre-write) instead of contentFM (the caller's raw fragment). Flip-tested: reverting to fragment-only validation turns the new adversarial test red; the merged validation turns it green. Co-Authored-By: Claude Sonnet 5 --- ingestion/internal/api/update.go | 30 +++++++++++++++++---------- ingestion/internal/api/update_test.go | 23 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 11 deletions(-) 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.