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