Skip to content

Commit a4e641c

Browse files
fix(notionmeetings): harden hydration fallbacks
1 parent 43845b2 commit a4e641c

7 files changed

Lines changed: 201 additions & 27 deletions

File tree

cmd/msgvault/cmd/notion_meetings.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ func runNotionMeetingsProbe(ctx context.Context, out io.Writer, client notionMee
118118
if _, err := client.ListUsers(ctx, ""); errors.Is(err, notionmeetings.ErrUserInformation) {
119119
_, _ = fmt.Fprintln(out, " User Information: unavailable (attendees remain display-only)")
120120
} else if err != nil {
121-
return fmt.Errorf("probe Notion User Information access: %w", err)
121+
_, _ = fmt.Fprintf(out, " User Information: unavailable (error: %v; attendees remain display-only)\n", err)
122122
} else {
123123
_, _ = fmt.Fprintln(out, " User Information: available")
124124
}

cmd/msgvault/cmd/notion_meetings_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,16 @@ func TestRunNotionMeetingsProbeDegradesWithoutUserInformation(t *testing.T) {
119119
assert.Contains(t, out.String(), "User Information: unavailable")
120120
}
121121

122+
func TestRunNotionMeetingsProbeDegradesOnTransientUserListingFailure(t *testing.T) {
123+
var out bytes.Buffer
124+
err := runNotionMeetingsProbe(context.Background(), &out, fakeNotionProbe{
125+
result: &notionmeetings.QueryResult{}, usersErr: errors.New("rate limit exceeded"),
126+
})
127+
require.NoError(t, err)
128+
assert.Contains(t, out.String(), "User Information: unavailable")
129+
assert.Contains(t, out.String(), "rate limit exceeded")
130+
}
131+
122132
func TestFinishNotionMeetingsImportRefreshesCommittedWritesOnFailure(t *testing.T) {
123133
refreshed := 0
124134
err := finishNotionMeetingsImport("work", &notionmeetings.ImportSummary{MeetingsAdded: 1},

docs/usage/meetings.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ Each meeting-note block becomes one `meeting_transcript` message in one
261261
`meeting` conversation, keyed by the block ID. The body contains deterministic
262262
title, time, attendee, summary, notes, and transcript sections. Raw format
263263
`notion_meeting_json` preserves the query object, block trees, Markdown page,
264-
minimal resolved users, and warnings.
264+
privacy-safe attendee display labels, minimal resolved users, and warnings.
265265
266266
Notion's `created_by` user is stored as creator metadata and is never assumed
267267
to be the organizer. Only attendees with provider-verified email addresses

internal/notionmeetings/format.go

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,17 @@ type rawBlockTree struct {
3636
}
3737

3838
type rawEvidence struct {
39-
SchemaVersion int `json:"schema_version"`
40-
Discovery json.RawMessage `json:"discovery"`
41-
MeetingBlock json.RawMessage `json:"meeting_block"`
42-
Summary rawBlockTree `json:"summary,omitzero"`
43-
Notes rawBlockTree `json:"notes,omitzero"`
44-
Transcript rawBlockTree `json:"transcript,omitzero"`
45-
PageMarkdown json.RawMessage `json:"page_markdown"`
46-
Canonical canonicalEvidence `json:"canonical"`
47-
ResolvedUsers []resolvedUser `json:"resolved_users,omitempty"`
48-
Warnings []string `json:"warnings,omitempty"`
39+
SchemaVersion int `json:"schema_version"`
40+
Discovery json.RawMessage `json:"discovery"`
41+
MeetingBlock json.RawMessage `json:"meeting_block"`
42+
Summary rawBlockTree `json:"summary,omitzero"`
43+
Notes rawBlockTree `json:"notes,omitzero"`
44+
Transcript rawBlockTree `json:"transcript,omitzero"`
45+
PageMarkdown json.RawMessage `json:"page_markdown"`
46+
Canonical canonicalEvidence `json:"canonical"`
47+
AttendeeLabels []string `json:"attendee_labels,omitempty"`
48+
ResolvedUsers []resolvedUser `json:"resolved_users,omitempty"`
49+
Warnings []string `json:"warnings,omitempty"`
4950
}
5051

5152
type canonicalEvidence struct {
@@ -86,8 +87,8 @@ func (h *HydratedMeeting) ArchiveSnapshot(sourceID int64, identifier, accountEma
8687
MeetingBlock: rawForBlock(h.MeetingBlock),
8788
Summary: rawForTree(h.SummaryTree), Notes: rawForTree(h.NotesTree),
8889
Transcript: rawForTree(h.TranscriptTree), PageMarkdown: rawForMarkdown(h.PageMarkdown),
89-
Canonical: canonicalEvidence{Summary: h.Summary, Notes: h.Notes, Transcript: h.Transcript},
90-
ResolvedUsers: h.ResolvedUsers, Warnings: h.Warnings,
90+
Canonical: canonicalEvidence{Summary: h.Summary, Notes: h.Notes, Transcript: h.Transcript},
91+
AttendeeLabels: h.AttendeeLabels, ResolvedUsers: h.ResolvedUsers, Warnings: h.Warnings,
9192
})
9293
if err != nil {
9394
return meetingarchive.Snapshot{}, fmt.Errorf("marshal Notion raw evidence: %w", err)

internal/notionmeetings/hydrate.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ type Hydrator struct {
6161
users map[string]User
6262
usersRead bool
6363
usersUnavailable bool
64+
usersFailure error
6465
requests int
6566
bytes int
6667
blocks int
@@ -124,11 +125,14 @@ func (h *Hydrator) Hydrate(ctx context.Context, meeting MeetingNote) (*HydratedM
124125
return nil, err
125126
}
126127
markdownTranscript := extractMarkdownTranscript(result.PageMarkdown.Markdown)
127-
if result.Transcript == "" && markdownTranscript != "" {
128+
markdownComplete := !result.PageMarkdown.Truncated && len(result.PageMarkdown.UnknownBlockIDs) == 0
129+
if result.Transcript == "" && markdownTranscript != "" && !markdownComplete {
130+
result.Warnings = append(result.Warnings, "page Markdown transcript was incomplete; transcript remains pending")
131+
} else if result.Transcript == "" && markdownTranscript != "" {
128132
result.Transcript = markdownTranscript
129133
result.MarkdownTranscriptFallback = true
130134
result.Warnings = append(result.Warnings, "structured transcript was empty; used page Markdown transcript")
131-
} else if result.Transcript != "" && markdownTranscript != "" &&
135+
} else if result.Transcript != "" && markdownTranscript != "" && markdownComplete &&
132136
normalizedContent(result.Transcript) != normalizedContent(markdownTranscript) {
133137
result.Warnings = append(result.Warnings, "structured transcript and page Markdown transcript differed")
134138
}
@@ -226,14 +230,12 @@ func (h *Hydrator) resolveAttendees(ctx context.Context, result *HydratedMeeting
226230
return err
227231
}
228232
page, err := h.source.ListUsers(ctx, cursor)
229-
if errors.Is(err, ErrUserInformation) {
233+
if err != nil {
230234
h.usersUnavailable = true
235+
h.usersFailure = err
231236
h.usersRead = true
232237
break
233238
}
234-
if err != nil {
235-
return fmt.Errorf("list Notion users: %w", err)
236-
}
237239
if err := h.reserveBytes(page.Raw); err != nil {
238240
return err
239241
}
@@ -256,7 +258,12 @@ func (h *Hydrator) resolveAttendees(ctx context.Context, result *HydratedMeeting
256258
}
257259
}
258260
if h.usersUnavailable {
259-
result.Warnings = append(result.Warnings, "Notion User Information access unavailable; attendee emails were not resolved")
261+
if errors.Is(h.usersFailure, ErrUserInformation) {
262+
result.Warnings = append(result.Warnings, "Notion User Information access unavailable; attendee emails were not resolved")
263+
} else {
264+
result.Warnings = append(result.Warnings,
265+
fmt.Sprintf("Notion User Information lookup failed: %v; attendee emails were not resolved", h.usersFailure))
266+
}
260267
}
261268

262269
for _, id := range result.Discovery.MeetingNotes.CalendarEvent.Attendees {

internal/notionmeetings/hydrate_test.go

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ import (
1111
)
1212

1313
type fakeHydrationSource struct {
14-
blocks map[string]*Block
15-
children map[string]map[string]*BlockPage
16-
markdown *MarkdownPage
17-
users map[string]*UserPage
18-
usersErr error
14+
blocks map[string]*Block
15+
children map[string]map[string]*BlockPage
16+
markdown *MarkdownPage
17+
users map[string]*UserPage
18+
usersErr error
19+
usersCalls int
1920
}
2021

2122
func (f *fakeHydrationSource) RetrieveBlock(_ context.Context, id string) (*Block, error) {
@@ -39,6 +40,7 @@ func (f *fakeHydrationSource) RetrievePageMarkdown(_ context.Context, _ string,
3940
}
4041

4142
func (f *fakeHydrationSource) ListUsers(_ context.Context, cursor string) (*UserPage, error) {
43+
f.usersCalls++
4244
if f.usersErr != nil {
4345
return nil, f.usersErr
4446
}
@@ -111,7 +113,7 @@ func completeHydrationSource() *fakeHydrationSource {
111113
"": {
112114
Results: []User{
113115
{ID: "user-1", Name: "Test Attendee", Type: "person", Person: UserPerson{Email: "attendee@example.com", EmailVerified: true}},
114-
{ID: "user-2", Name: "Unresolved Attendee", Type: "person"},
116+
{ID: "user-2", Name: "Unresolved Attendee", Type: "person", Person: UserPerson{Email: "unverified@example.com"}},
115117
},
116118
},
117119
},
@@ -152,6 +154,8 @@ func TestHydratorBuildsCanonicalSnapshotWithoutInventingOrganizer(t *testing.T)
152154
assert.Equal(RawFormat, snapshot.RawFormat)
153155
assert.Contains(string(snapshot.Raw), `"discovery"`)
154156
assert.Contains(string(snapshot.Raw), `"page_markdown"`)
157+
assert.Contains(string(snapshot.Raw), `"attendee_labels":["Test Attendee","Unresolved Attendee"]`)
158+
assert.NotContains(string(snapshot.Raw), "unverified@example.com")
155159
assert.NotContains(string(snapshot.Raw), "user@example.com", "configured identity must not enter provider evidence")
156160
assert.Contains(string(snapshot.Metadata), `"creator_user_id":"creator-1"`)
157161
assert.Contains(string(snapshot.Metadata), `"unresolved_attendee_ids":["user-2"]`)
@@ -171,6 +175,66 @@ func TestHydratorUsesMarkdownTranscriptFallback(t *testing.T) {
171175
assert.Contains(hydrated.Warnings, "structured transcript was empty; used page Markdown transcript")
172176
}
173177

178+
func TestHydratorLeavesIncompleteMarkdownTranscriptPending(t *testing.T) {
179+
tests := []struct {
180+
name string
181+
mutate func(*MarkdownPage)
182+
}{
183+
{
184+
name: "truncated",
185+
mutate: func(page *MarkdownPage) {
186+
page.Truncated = true
187+
},
188+
},
189+
{
190+
name: "unknown blocks",
191+
mutate: func(page *MarkdownPage) {
192+
page.UnknownBlockIDs = []string{"transcript-child-1"}
193+
},
194+
},
195+
}
196+
197+
for _, tt := range tests {
198+
t.Run(tt.name, func(t *testing.T) {
199+
assert := assert.New(t)
200+
source := completeHydrationSource()
201+
empty := paragraph("transcript-1", "", false)
202+
source.blocks["transcript-1"] = &empty
203+
source.markdown.Markdown = "# Weekly planning\n\n## Transcript\nTest Speaker: Partial transcript."
204+
tt.mutate(source.markdown)
205+
206+
hydrated, err := NewHydrator(source).Hydrate(context.Background(), hydrationMeeting())
207+
require.NoError(t, err)
208+
assert.Empty(hydrated.Transcript)
209+
assert.False(hydrated.MarkdownTranscriptFallback)
210+
assert.Contains(hydrated.Warnings, "page Markdown transcript was incomplete; transcript remains pending")
211+
})
212+
}
213+
}
214+
215+
func TestHydratorDoesNotCompareIncompleteMarkdownTranscript(t *testing.T) {
216+
source := completeHydrationSource()
217+
source.markdown.Markdown = "# Weekly planning\n\n## Transcript\nTest Speaker: Partial conflicting transcript."
218+
source.markdown.Truncated = true
219+
220+
hydrated, err := NewHydrator(source).Hydrate(context.Background(), hydrationMeeting())
221+
require.NoError(t, err)
222+
assert.NotContains(t, hydrated.Warnings, "structured transcript and page Markdown transcript differed")
223+
}
224+
225+
func TestHydratorDoesNotWarnAboutIncompleteMarkdownWithoutTranscript(t *testing.T) {
226+
source := completeHydrationSource()
227+
empty := paragraph("transcript-1", "", false)
228+
source.blocks["transcript-1"] = &empty
229+
source.markdown.Markdown = "# Weekly planning\n\nMeeting context only."
230+
source.markdown.UnknownBlockIDs = []string{"unrelated-block-1"}
231+
232+
hydrated, err := NewHydrator(source).Hydrate(context.Background(), hydrationMeeting())
233+
require.NoError(t, err)
234+
assert.Empty(t, hydrated.Transcript)
235+
assert.NotContains(t, hydrated.Warnings, "page Markdown transcript was incomplete; transcript remains pending")
236+
}
237+
174238
func TestHydratorRejectsRepeatedChildCursor(t *testing.T) {
175239
source := completeHydrationSource()
176240
source.children["notes-1"][""] = &BlockPage{HasMore: true, NextCursor: "same"}
@@ -197,3 +261,23 @@ func TestHydratorDegradesWhenUserInformationIsUnavailable(t *testing.T) {
197261
require.NoError(err)
198262
assert.Contains(snapshot.Body, "Attendees: user-1, user-2")
199263
}
264+
265+
func TestHydratorDegradesAndCachesTransientUserListingFailure(t *testing.T) {
266+
assert := assert.New(t)
267+
require := require.New(t)
268+
source := completeHydrationSource()
269+
source.usersErr = errors.New("rate limit exceeded")
270+
hydrator := NewHydrator(source)
271+
272+
first, err := hydrator.Hydrate(context.Background(), hydrationMeeting())
273+
require.NoError(err)
274+
assert.Empty(first.Attendees)
275+
assert.Equal([]string{"user-1", "user-2"}, first.AttendeeLabels)
276+
assert.Contains(first.Warnings, "Notion User Information lookup failed: rate limit exceeded; attendee emails were not resolved")
277+
278+
second, err := hydrator.Hydrate(context.Background(), hydrationMeeting())
279+
require.NoError(err)
280+
assert.Empty(second.Attendees)
281+
assert.Contains(second.Warnings, "Notion User Information lookup failed: rate limit exceeded; attendee emails were not resolved")
282+
assert.Equal(1, source.usersCalls)
283+
}

internal/notionmeetings/importer_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"database/sql"
66
"encoding/json"
7+
"errors"
78
"testing"
89
"time"
910

@@ -138,6 +139,14 @@ func TestImporterDetectsHydratedSnapshotChangesWithoutDiscoveryEdit(t *testing.T
138139
},
139140
wantRaw: "Revised page context.",
140141
},
142+
{
143+
name: "attendee display label",
144+
mutate: func(source *fakeImportSource) {
145+
source.users[""].Results[1].Name = "Renamed Unresolved Attendee"
146+
},
147+
wantBody: "Attendees: Test Attendee, Renamed Unresolved Attendee",
148+
wantRaw: `"attendee_labels":["Test Attendee","Renamed Unresolved Attendee"]`,
149+
},
141150
}
142151

143152
for _, tt := range tests {
@@ -296,6 +305,69 @@ func TestImporterRetriesPendingTranscriptOutsideVisibleLimit(t *testing.T) {
296305
assert.Contains(body, "Transcript arrived.")
297306
}
298307

308+
func TestImporterRetriesTruncatedMarkdownTranscriptUntilComplete(t *testing.T) {
309+
assert := assert.New(t)
310+
require := require.New(t)
311+
st, source, imp := newImporterFixture(t)
312+
empty := paragraph("transcript-1", "", false)
313+
source.blocks["transcript-1"] = &empty
314+
source.markdown.Markdown = "# Weekly planning\n\n## Transcript\nTest Speaker: Partial transcript."
315+
source.markdown.Truncated = true
316+
source.markdown.Raw = json.RawMessage(`{"object":"page_markdown","id":"page-1","truncated":true}`)
317+
318+
first, err := imp.Import(context.Background(), ImportOptions{Identifier: "work"})
319+
require.NoError(err)
320+
state := lastNotionState(t, st, first.SourceID)
321+
require.Len(state.Pending, 1)
322+
assert.Equal("meeting-1", state.Pending[0].BlockID)
323+
var body string
324+
require.NoError(st.DB().QueryRow(`SELECT body_text FROM message_bodies`).Scan(&body))
325+
assert.NotContains(body, "Partial transcript.")
326+
327+
fullDiscovery, err := json.Marshal(hydrationMeeting())
328+
require.NoError(err)
329+
source.blocks["meeting-1"].Raw = fullDiscovery
330+
source.query.Results = nil
331+
source.markdown.Markdown = "# Weekly planning\n\n## Transcript\nTest Speaker: Complete transcript."
332+
source.markdown.Truncated = false
333+
source.markdown.Raw = json.RawMessage(`{"object":"page_markdown","id":"page-1","truncated":false}`)
334+
imp.now = func() time.Time { return time.Date(2026, 8, 29, 18, 1, 0, 0, time.UTC) }
335+
336+
second, err := imp.Import(context.Background(), ImportOptions{Identifier: "work"})
337+
require.NoError(err)
338+
assert.Equal(int64(1), second.MaintenanceRetries)
339+
assert.Equal(int64(1), second.MeetingsUpdated)
340+
state = lastNotionState(t, st, second.SourceID)
341+
assert.Empty(state.Pending)
342+
require.NoError(st.DB().QueryRow(`SELECT body_text FROM message_bodies`).Scan(&body))
343+
assert.Contains(body, "Complete transcript.")
344+
}
345+
346+
func TestImporterContinuesWhenUserListingFailsTransiently(t *testing.T) {
347+
assert := assert.New(t)
348+
require := require.New(t)
349+
st, source, imp := newImporterFixture(t)
350+
source.usersErr = errors.New("rate limit exceeded")
351+
352+
summary, err := imp.Import(context.Background(), ImportOptions{Identifier: "work"})
353+
require.NoError(err)
354+
assert.Equal(int64(1), summary.MeetingsAdded)
355+
assert.Zero(summary.Errors)
356+
357+
var messageID int64
358+
require.NoError(st.DB().QueryRow(
359+
`SELECT id FROM messages WHERE source_message_id = 'meeting-1'`,
360+
).Scan(&messageID))
361+
raw, err := st.GetMessageRaw(messageID)
362+
require.NoError(err)
363+
assert.Contains(string(raw), "Notion User Information lookup failed: rate limit exceeded")
364+
var participants int
365+
require.NoError(st.DB().QueryRow(
366+
`SELECT COUNT(*) FROM message_recipients WHERE message_id = ?`, messageID,
367+
).Scan(&participants))
368+
assert.Zero(participants)
369+
}
370+
299371
func TestImporterContinuesVisibleWorkWhenPendingMeetingCannotBeLoaded(t *testing.T) {
300372
assert := assert.New(t)
301373
require := require.New(t)

0 commit comments

Comments
 (0)