Skip to main content

Bug fixes

Structured-notes serialization leak for note-less meetings (issue #881) _notes_to_markdown in libs/granola/normalize.py renders Granola’s structured ProseMirror notes to text for the exported .md file and JSON sidecar. Once the input was identified as a structured doc, the function returned rendered or str(raw_notes) — so a doc that rendered to no printable content fell through the falsy or and re-emitted the raw serialization instead. The canonical “empty editor” doc (a lone empty paragraph) is exactly that shape, so any meeting whose notes were never written (or fully cleared) exported its notes as verbatim JSON — or, for cache values that arrive as nested objects after json.loads, as a single-quoted Python dict repr that is neither JSON nor text. The notes fallback field shared the defect through the same line. The function now returns the render result unconditionally once the input parses as a structured doc: an empty render means “the doc has no printable content,” whose correct rendering is the empty string. Unstructured note strings are untouched — they still pass through as-is. Scope failures mislabeled when the Attio error body carries a code (issue #890) classify_error in libs/attio/errors.py funnels every caught Attio exception into a ClassifiedError bucket. A re-parse branch recovers Attio’s verbatim code/message from error bodies the generated SDK would otherwise mask behind a Pydantic validation error — but it sat above the heuristic that buckets “…do not have permission…” messages as insufficient_scope with a GET /v2/self remediation. A body-carrying 404 with code="not_found" whose message carried that wording therefore classified as a genuine 404: the envelope was legible, but nothing flagged it as a permission gap, and triage pointed at a mistyped slug or a deleted object instead of the token’s scopes. The scope check now runs on the re-parse path as well, matching on the re-parsed body message’s substring — a body-carrying 403 with a truthful code="unauthorized" and no scope wording keeps its authoritative classification, and a scope-y body is caught even when the exception’s own str() is Pydantic noise (a ResponseValidationError wrapper). The override preserves the real status_code and type from the body, so the envelope carries both the actionable insufficient_scope bucket and Attio’s HTTP facts.
Last modified on October 2, 2026