diff --git a/ingestion/internal/api/update.go b/ingestion/internal/api/update.go index e493006..c8d015c 100644 --- a/ingestion/internal/api/update.go +++ b/ingestion/internal/api/update.go @@ -10,6 +10,7 @@ import ( "time" "github.com/mathiasbq/hyperguild/ingestion/internal/brain" + "github.com/mathiasbq/hyperguild/ingestion/internal/claim" ) // ContentHash returns the lowercase hex sha256 of b. It is the note's @@ -98,14 +99,39 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash, } priorHash = ContentHash(prior) + // A caller may lead opts.Content with its own frontmatter fence to set or + // change fields on supersede (e.g. the claim envelope). Split it off + // before it reaches the body — left un-split, it would render as a + // second, inert `---`-fenced block of literal text rather than taking + // effect, and any claim-envelope fields inside it would reach disk + // 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 { + if l.key != "" { + fm.set(l.key, l.value) + } + } fm.set("updated_at", time.Now().UTC().Format(time.RFC3339)) fm.set("supersedes", priorHash) if opts.Reason != "" { fm.set("supersede_reason", opts.Reason) } - out := []byte(fm.render() + opts.Content) + 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 d677a29..f47dd4b 100644 --- a/ingestion/internal/api/update_test.go +++ b/ingestion/internal/api/update_test.go @@ -52,6 +52,91 @@ func TestUpdateNoteSupersedesAndStamps(t *testing.T) { assert.Contains(t, s, "supersede_reason: facts changed") } +func TestUpdateNoteRejectsInvalidEnvelopeAndWritesNothing(t *testing.T) { + // hyperguild#111: WriteNote validates the claim envelope before create; + // UpdateNote didn't before supersede, so the same rule must hold on + // change as on birth, fail-closed, nothing written on rejection. + brainDir := t.TempDir() + original := "---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\n---\n# Old\n\nold body\n" + rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original) + + _, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{ + Path: rel, + Content: "---\nhalf_life: forever\n---\n# New\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 TestUpdateNoteRejectsAppliesWithoutProvenance(t *testing.T) { + brainDir := t.TempDir() + original := "---\nwing: jepa-fx\nhall: facts\n---\nold\n" + rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original) + + _, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{ + Path: rel, + Content: "---\napplies: universal\n---\nnew\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)) +} + +func TestUpdateNoteAcceptsValidEnvelopeAndMergesIntoFrontmatter(t *testing.T) { + brainDir := t.TempDir() + rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", + "---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\n---\nold\n") + + _, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{ + Path: rel, + Content: "---\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\nnew body\n", + }) + require.NoError(t, err) + + got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel))) + require.NoError(t, err) + s := string(got) + assert.Contains(t, s, "half_life: durable") + assert.Contains(t, s, "applies: universal") + assert.Contains(t, s, "claim_provenance: asserted") + assert.Contains(t, s, "wing: jepa-fx", "prior fields survive") + assert.Contains(t, s, "new body") + assert.Equal(t, 2, strings.Count(s, "---\n"), + "exactly one open+close fence pair — the caller's own fence must not leak in as a second, inert block") +} + +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. + brainDir := t.TempDir() + rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", + "---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\ncustom: keep-me\n---\n# Old\n\nold body\n") + + relPath, hash, priorHash, err := UpdateNote(brainDir, UpdateNoteOptions{ + Path: rel, + Content: "# New\n\nnew body\n", + Reason: "facts changed", + }) + require.NoError(t, err) + assert.Equal(t, rel, relPath) + assert.NotEmpty(t, hash) + assert.NotEqual(t, hash, priorHash) + + got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel))) + require.NoError(t, err) + s := string(got) + assert.Contains(t, s, "# New") + assert.Contains(t, s, "custom: keep-me") + assert.Contains(t, s, "supersedes: "+priorHash) +} + func TestUpdateNoteResolvesByWingHallSlug(t *testing.T) { brainDir := t.TempDir() seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",