项目文件夹

文件
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

8.8 KiB

ADR-0011: Schema validation policy (strict-decode default)

Current state (2026-06-11). The strict-default migration described below completed. The legacy NOTEBOOKLM_STRICT_DECODE=0 warn-and-return-None soft-mode opt-out was retired in v0.7.0; rpc/_safe_index.py is now strict-only and raises UnknownRPCMethodError on schema drift regardless of the env var.

Status

Accepted (Tier 13 PR 13.9a).

Context

NotebookLM's batchexecute responses are undocumented and obfuscated. Google reshapes them without notice. Every decoder call site in the client walks nested positional lists by integer indices that are pinned only by what we captured in cassettes and observed in production. When Google rotates a shape (a single index shifts by one, a leaf becomes a wrapper, an inner list becomes a dict), the affected call site either: (a) crashes with a raw IndexError / TypeError from inside a feature module, or (b) silently degrades to whatever the surrounding code happened to do with None.

The Tier-12 remediation introduced notebooklm.rpc.safe_index (src/notebooklm/rpc/_safe_index.py) as the single shared schema-drift point: callers descend through it by integer indices, and on descent failure the helper raises notebooklm.exceptions.UnknownRPCMethodError. PR-12 era introduced the helper with a temporary NOTEBOOKLM_STRICT_DECODE=0 soft mode so the migration of ~30 call sites from hand-rolled try/except IndexError blocks to safe_index could land without immediately breaking downstream code that relied on the silently-degrades-to-None contract. That soft mode is now retired.

Two facts shape this ADR:

  1. The migration is far enough along. The shared safe_index helper is the policy point for the ~30 batchexecute descent sites that migrated off hand-rolled try/except IndexError blocks; every migrated site threads a method_id / source label through. A small number of historical positional decoders remained in feature modules (now under _artifact/downloads.py, _artifact/polling.py, and _chat/) where the parsing logic predates the helper; each guards its own descent with feature-local error recovery, so the strict-default flip does not regress those sites — they will be migrated in Tier 13.x follow-ups. The default switch is no longer gated on the remaining migration work.
  2. Soft mode masks real drift. In soft mode a Google-side shape change produces a None return from a feature method (an empty summary, a missing artifact id, an empty sources list), which is indistinguishable from a legitimately-empty payload. Operators discover the drift only via downstream consequence (a cron job that reports "no notebooks today") rather than at the decoder boundary where the drift actually happened.

The two integration test files tests/integration/test_artifacts_drift.py and tests/integration/test_get_summary_drift.py already pin both soft-mode and strict-mode contracts for the representative call sites, which gives us confidence that the strict path is itself well-characterised — flipping the default is a default change, not a behaviour-discovery exercise.

Three details that shaped this ADR:

  1. The env-var name was preserved for the runway, then retired. During the transition, NOTEBOOKLM_STRICT_DECODE=0 restored soft mode for one release window. Current code ignores the variable for drift policy: safe_index raises regardless of its value.
  2. The opt-out was explicit and bounded. Downstream code that was ingesting None as a legitimate "decoder couldn't descend" signal had one cycle to adopt except UnknownRPCMethodError (an RPCError subclass). The soft-mode path was then removed in v0.7.0.
  3. The exception remains under RPCError. UnknownRPCMethodError is a subclass of DecodingError which is a subclass of RPCError. Any existing except RPCError: handler already covers the strict-mode raise — the new default does not force downstream callers to add a new except-clause unless they are intentionally treating drift as a non-error sentinel.

Decision

safe_index raises UnknownRPCMethodError on every descent failure. NOTEBOOKLM_STRICT_DECODE is ignored for compatibility with old environments that still set it; it no longer enables soft mode.

Behavioural contract

The single decision point is rpc/_safe_index.py::safe_index. It does not consult _env; it either returns the successfully descended value or raises UnknownRPCMethodError with method_id, source, path, and a truncated data_at_failure preview.

Historical opt-out lifecycle

  • PR 13.9a: unset meant strict; explicit =0 temporarily restored soft mode.
  • v0.5.0: explicit soft-mode fallback logged the drift warning and emitted DeprecationWarning before returning None.
  • v0.7.0: soft-mode path was removed. NOTEBOOKLM_STRICT_DECODE became a no-op for schema-drift policy and no longer affects decode behavior.

Test surface

tests/unit/test_strict_decode_default.py is the canonical pin for the retired opt-out. It asserts that unset env and every old truthy/falsy NOTEBOOKLM_STRICT_DECODE spelling still result in safe_index raising UnknownRPCMethodError on drift. tests/unit/test_safe_index.py pins the helper's structured diagnostics and import surface.

Consequences

Wanted:

  • Google-side shape changes surface at the decoder boundary as typed exceptions with method_id, path, source, and data_at_failure attributes — operators learn about drift before downstream consequences accumulate.
  • The "the call site returned None but I don't know whether the payload was empty or the decoder failed" ambiguity is eliminated: empty payload still produces an empty value at the feature boundary; drift now produces an exception with structured context.
  • Integration tests that exercise real-shape cassettes implicitly test the strict path (the production default), increasing the value of every cassette playback as a drift canary.
  • Adding a new feature call site no longer requires the author to reason about "should this default to soft or strict?" — the answer is always strict, and the historical env-var opt-out is documented only to explain why old environments that still set it see no effect.

Unwanted:

  • Downstream code that relied on None as a legitimate "shape didn't match" sentinel now sees a raised exception. The migration window has closed; callers should catch UnknownRPCMethodError / DecodingError / RPCError depending on how broadly they want to handle drift.
  • Tests that used to exercise soft-mode fallback now assert strict-only behavior or guard legitimate absent optional fields before calling safe_index.

Alternatives considered

Leave the default at 0 indefinitely; document strict mode as a recommended opt-in. Rejected. The migration is complete; the asymmetry between "the decoder knows the payload doesn't match" and "the caller silently returns an empty value" is the exact failure mode this helper was introduced to eliminate. Leaving the soft default permanent defeats the purpose of the migration that landed in Tier-12 and preserves the very ambiguity ADR-0006 (cassette scrubbing) and ADR-0007 (test monkeypatch policy) work to remove from the test suite.

Flip the default AND simultaneously delete the soft-mode branch. Rejected for the original PR. The opt-out gave one release of migration runway for downstream consumers who were relying on the pre-flip contract. The branch was deleted later, after the warning window.

Move the toggle to a runtime constructor argument (NotebookLMClient(strict_decode=True)) instead of an env var. Rejected. Decoder strictness is a process-wide deployment decision, not a per-client one. A constructor argument would force every test fixture and every CLI invocation to thread the toggle through, while the env var is set once at the process boundary and reads atomically on each safe_index call. The CLI does not need to expose a --strict-decode flag either — operators set the env in their shell or systemd unit and the entire process picks up the policy.

Make safe_index raise unconditionally and delete the env var. Rejected for the soft-rollout window, but this is the current behavior except that the env var remains a no-op compatibility spelling rather than a process-start failure.

Couple this flip to the __all__ audit (PR 13.9b). Rejected. The __all__ audit and migration doc (originally part of t13-9) must wait for t13-8's session/kernel landing to stabilise the public surface; the strict-decode flip has zero overlap with t13-8's code moves and can ship in parallel with waves 4-8 of Tier-13. Splitting t13-9 into t13-9a (this ADR + flip) and t13-9b (__all__ audit) unblocks the parallel window and shortens the tier's critical path.