fix(api): validate the merged envelope on supersede, not the caller's fragment
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 <noreply@anthropic.com>
This commit is contained in:
@@ -107,17 +107,6 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
|||||||
// unvalidated (hyperguild#111: the same rule that guards note birth in
|
// unvalidated (hyperguild#111: the same rule that guards note birth in
|
||||||
// writeHallNote must guard a change to the same fields).
|
// writeHallNote must guard a change to the same fields).
|
||||||
contentFM, contentBody := parseFrontmatter(opts.Content)
|
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))
|
fm, _ := parseFrontmatter(string(prior))
|
||||||
for _, l := range contentFM.lines {
|
for _, l := range contentFM.lines {
|
||||||
@@ -131,6 +120,25 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
|||||||
fm.set("supersede_reason", opts.Reason)
|
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)
|
out := []byte(fm.render() + contentBody)
|
||||||
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
||||||
return "", "", "", fmt.Errorf("write: %w", err)
|
return "", "", "", fmt.Errorf("write: %w", err)
|
||||||
|
|||||||
@@ -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")
|
"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) {
|
func TestUpdateNoteWithoutEnvelopeFieldsIsUnchangedFromToday(t *testing.T) {
|
||||||
// Regression pin: plain fence-less content (the common case) must behave
|
// Regression pin: plain fence-less content (the common case) must behave
|
||||||
// exactly as it did before the envelope-merge fix.
|
// exactly as it did before the envelope-merge fix.
|
||||||
|
|||||||
Reference in New Issue
Block a user