> ## Documentation Index
> Fetch the complete documentation index at: https://gtmsdk.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Fix: Granola empty-notes leak and mislabeled Attio scope failures

> Two same-day fixes: Granola meetings with empty structured notes exported the raw serialization instead of the rendered text, and Attio permission-denied bodies carrying a code kept a not-found classification with no scope remediation.

## 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.